diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1bcdd8..2e29f51 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,3 +30,20 @@ jobs: # footprint gates stay off (as on starbox). cov-min: 65 secrets: inherit + + # The examples/ build-your-own demos are their own Go module (minimal deps, out + # of the main coverage gate), so they build in a separate, non-gating job rather + # than inside the reusable CI. This just proves the demos and the kit API they + # use still compile and vet clean. + examples: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + - name: Build and vet the examples module + working-directory: examples + run: | + go build ./... + go vet ./... diff --git a/README.md b/README.md index 255594a..c072b9c 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,27 @@ direct.star:1:7: undefined: undefined_name check: 1 problem(s) found ``` +## Build your own CLI + +StarCLI is the **standard, fully-loaded** build. It is assembled from a small +reusable core — the [`kit`](kit) package — that you can use to build your **own** +CLI: a few-line Go shell that embeds your Starlark scripts and wires only the +modules you need. Your shell and the standard StarCLI construct their runtime +through the same path, so they behave identically. + +```go +//go:embed app.star +var app string + +func main() { + // embed the script + pick modules + run, in one call + kit.Run(app, kit.WithModules("json", "math")) +} +``` + +See [`examples/`](examples) for runnable demos (a minimal shell, and one wiring a +single starpkg module) and the build-your-own quickstart. + ## Configuration StarCLI can be configured through a config file (YAML format) using the `-C` or `--config` flag: diff --git a/cli/box.go b/cli/box.go index cf5c8f0..3a11007 100644 --- a/cli/box.go +++ b/cli/box.go @@ -8,6 +8,8 @@ import ( "sync" "github.com/1set/starbox" + "github.com/1set/starcli/kit" + "github.com/1set/starlet" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -43,10 +45,15 @@ type BoxOpts struct { execCmd bool // derived from the grant: construct cmd ENABLED (allow-all) } -// BuildBox creates a new Starbox with the given options. By default every wired -// module is available (the open posture); a restrictive --caps tier / STAR_CAPS -// or an --allow-* flag installs a capability load gate so only the permitted -// modules may be loaded. +// BuildBox creates a new Starbox with the given options. It is the standard, +// fully-loaded instance of the shared kit core (kit.Box): the CLI-specific +// concerns — capability gating, scenario-driven printer, file logging — are +// resolved here and handed to kit as options, so the turnkey CLI and any +// build-your-own shell construct their runtime through exactly the same path. +// +// By default every wired module is available (the open posture); a restrictive +// --caps tier / STAR_CAPS or an --allow-* flag installs a capability load gate so +// only the permitted modules may be loaded. func BuildBox(opts *BoxOpts) (*starbox.Starbox, error) { if !validCapsTier(opts.caps) { return nil, fmt.Errorf("unknown --caps value %q (want: open, full, network, or safe)", opts.caps) @@ -56,14 +63,33 @@ func BuildBox(opts *BoxOpts) (*starbox.Starbox, error) { // only when the grant permits execution; otherwise cmd loads disabled. opts.execCmd = grant.execCmd - var box *starbox.Starbox - if grant.unrestricted() { - // Default open posture: no load gate, every wired module is loadable. - box = starbox.New(opts.name) - } else { + // set print function: TODO: for scenario, and throw errors + pf, err := getPrinterFunc(opts.scenario, opts.printerName) + if err != nil { + return nil, err + } + + kitOpts := []kit.Option{ + // execution budgets (0 == unlimited): a step budget bounds runaway loops + // that a wall-clock timeout cannot stop; an output cap bounds result size. + kit.WithMaxSteps(opts.maxSteps), + kit.WithMaxOutputEntries(opts.maxOutput), + // the CLI prints results itself, so keep raw Starlark values. + kit.WithOutputConversion(false), + kit.WithGlobalReassign(opts.globalReassign), + kit.WithRecursion(opts.recursion), + kit.WithPrintFunc(pf), + // every starpkg module is resolved on demand from the CLI's registry. + kit.WithDynamicLoader(func(name string) (starlet.ModuleLoader, error) { + return loadCLIModuleByName(opts, name) + }), + kit.WithModules(opts.moduleToLoad...), + } + + if !grant.unrestricted() { // A tier/flag narrowed the grant: gate loading to the permitted set. policy := starbox.Policy{Modules: starbox.ModuleAllow{Names: grant.allowedModules(getDefaultModules())}} - box = starbox.NewWithPolicy(opts.name, policy) + kitOpts = append(kitOpts, kit.WithPolicy(policy)) } // Route the script's `log` module output to a file when requested (C-4): @@ -74,45 +100,14 @@ func BuildBox(opts *BoxOpts) (*starbox.Starbox, error) { if err != nil { return nil, err } - box.SetLogger(lg) + kitOpts = append(kitOpts, kit.WithLogger(lg)) } if strings.TrimSpace(opts.includePath) != "" { - box.SetFS(os.DirFS(opts.includePath)) + kitOpts = append(kitOpts, kit.WithFS(os.DirFS(opts.includePath))) } - // execution budgets (0 == unlimited): a step budget bounds runaway loops - // that a wall-clock timeout cannot stop; an output cap bounds result size. - box.SetMaxExecutionSteps(opts.maxSteps) - box.SetMaxOutputEntries(opts.maxOutput) - - // machine-level knobs - mac := box.GetMachine() - mac.SetOutputConversionEnabled(false) - if opts.globalReassign { - mac.EnableGlobalReassign() - } else { - mac.DisableGlobalReassign() - } - if opts.recursion { - mac.EnableRecursionSupport() - } else { - mac.DisableRecursionSupport() - } - - // set print function: TODO: for scenario, and throw errors - pf, err := getPrinterFunc(opts.scenario, opts.printerName) - if err != nil { - return nil, err - } - box.SetPrintFunc(pf) - - // load modules - box.SetModuleSet(starbox.EmptyModuleSet) // force clean the module set - if err := loadModules(box, opts); err != nil { - return nil, err - } - return box, nil + return kit.New(opts.name, kitOpts...).Box() } var ( diff --git a/cli/mods.go b/cli/mods.go index 0dd8ed1..8cb638c 100644 --- a/cli/mods.go +++ b/cli/mods.go @@ -4,7 +4,6 @@ import ( "fmt" "sort" - "github.com/1set/starbox" "github.com/1set/starcli/config" "github.com/1set/starcli/module/args" "github.com/1set/starcli/module/sys" @@ -57,25 +56,10 @@ func getDefaultModules() []string { return allMods } -// loadModules loads the given modules into the Starbox instance. -func loadModules(box *starbox.Starbox, opts *BoxOpts) error { - usrMods := opts.moduleToLoad - if len(usrMods) == 0 { - // no modules to load - log.Debugw("no modules to load", "user_modules", usrMods) - return nil - } - - // set dynamic module loader - box.SetDynamicModuleLoader(func(name string) (starlet.ModuleLoader, error) { - return loadCLIModuleByName(opts, name) - }) - box.AddModulesByName(usrMods...) - - // all is well - return nil -} - +// loadCLIModuleByName is the CLI's module registry: it resolves a module name to +// its loader on demand. BuildBox installs it as the kit dynamic loader, so the +// turnkey CLI exposes every starpkg module while a build-your-own shell wires +// only the few it imports. func loadCLIModuleByName(opts *BoxOpts, name string) (starlet.ModuleLoader, error) { switch name { case args.ModuleName: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..842cf3a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,91 @@ +# Build your own — starcli `kit` examples + +`starcli` is the **standard, fully-loaded** Star\* CLI. But it is built on a small +reusable core — the [`kit`](../kit) package — that you can use to assemble your +**own** CLI: a single Go shell that embeds your Starlark scripts and wires only +the modules you need. The standard `starcli` and your shell construct their +runtime through exactly the same path, so they behave the same. + +> **The idea:** Go is the shell, Starlark is the app. Your `main.go` stays a few +> lines and never changes; your logic lives in `.star` files you `//go:embed`. + +This folder is a **separate Go module** on purpose: each demo depends on only the +runtime plus the modules it wires, so its `go.mod` is the honest, minimal +dependency tree a real shell would have. + +## Run the demos + +```bash +cd examples +go run ./hello # embed one .star, run it with a builtin module +go run ./qrcard # wire one starpkg module (qrcode) into the shell +``` + +## `hello` — the smallest shell + +The entire program is the embed + one call. Everything else is Starlark. + +```go +//go:embed app.star +var app string + +func main() { + if _, err := kit.Run(app, kit.WithModules("math")); err != nil { + log.Fatal(err) + } +} +``` + +## `qrcard` — wiring a starpkg module + +A build-your-own shell picks the domain modules it wants and hands their loaders +to the kit. Here the shell imports just `github.com/starpkg/qrcode`: + +```go +import "github.com/starpkg/qrcode" + +func main() { + kit.Run(app, kit.WithLoader(qrcode.ModuleName, qrcode.NewModule().LoadModule())) +} +``` + +Its dependency tree is `starbox` + `qrcode` and nothing else — none of the other +starpkg modules the turnkey `starcli` carries. + +## How modules resolve + +`kit` follows starbox's resolution order, so you mix three styles freely: + +| You write | Resolves as | Use for | +|---|---|---| +| `kit.WithModules("json", "math")` | starlet **builtins** (auto) | the standard library | +| `kit.WithLoader("qrcode", loader)` | an **explicit** loader you import | a specific starpkg/custom module | +| `kit.WithDynamicLoader(fn)` | a **registry** resolved on demand | exposing many modules at once (this is how `starcli` itself wires every starpkg module) | + +## Shipping a tree of scripts + +For more than one script, embed a whole directory and run an entry point — the +other files are reachable via `load()`: + +```go +//go:embed scripts/*.star +var scripts embed.FS + +func main() { + kit.RunFS(scripts, "scripts/main.star", kit.WithModules("json")) +} +``` + +## Common knobs + +```go +kit.Run(app, + kit.WithModules("json", "math"), + kit.WithGlobal("env", "prod"), // inject host values as script globals + kit.WithMaxSteps(1_000_000), // bound runaway loops + kit.WithMaxOutputEntries(100), // bound result size + kit.WithPrintFunc(myPrinter), // control how print() renders +) +``` + +See the [`kit` package docs](../kit/kit.go) for the full option set. diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..6ead24a --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,35 @@ +// Separate module on purpose: these build-your-own demos each depend on only the +// runtime plus the modules they wire (not starcli's full module set), so this is +// the honest, minimal dependency tree a real shell would have. It also keeps the +// example mains out of the main module's coverage gate. CI builds it in its own +// non-gating job. The replace points at the in-repo kit until starcli is tagged. +module github.com/1set/starcli/examples + +go 1.25.8 + +require ( + github.com/1set/starcli v0.0.0 + github.com/starpkg/qrcode v0.1.0 +) + +require ( + github.com/1set/starbox v0.2.0 // indirect + github.com/1set/starlet v0.2.2 // indirect + github.com/1set/starlight v0.2.0 // indirect + github.com/boombuler/barcode v1.1.0 // indirect + github.com/chzyer/readline v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/h2so5/here v0.0.0-20200815043652-5e14eb691fae // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/spyzhov/ajson v0.9.6 // indirect + github.com/starpkg/base v0.1.1 // indirect + go.starlark.net v0.0.0-20260324133313-ffb3f39dd27a // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/sys v0.45.0 // indirect +) + +replace github.com/1set/starcli => ../ diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 0000000..86b1c2c --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,61 @@ +github.com/1set/starbox v0.2.0 h1:49VR41rprA0r5SBS2CQcwe1eRz/2WbmQ61J3RAPIzP4= +github.com/1set/starbox v0.2.0/go.mod h1:9OXM57bi3G9tGf/oxPo4rKAZFMHznHkFIROAusmNvBk= +github.com/1set/starlet v0.2.2 h1:TWEbuY5O3291GvRwxHLMiJCSGWhI1ZMdXe+nCtBejW0= +github.com/1set/starlet v0.2.2/go.mod h1:3Sz9ToVkumS1OMojGmb37iLqd69UVTr1PWFOXaiC+rA= +github.com/1set/starlight v0.2.0 h1:W9yulJYANolyMLMOH0M4xcW8RQVpi68opSdNmoipcic= +github.com/1set/starlight v0.2.0/go.mod h1:o9KiJBpy92daHyNHBUwS0nFIjjLxLM/XmRxAZf4FIaE= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/chzyer/logex v1.2.0 h1:+eqR0HfOetur4tgnC8ftU5imRnhi4te+BadWS95c5AM= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/readline v1.5.0 h1:lSwwFrbNviGePhkewF1az4oLmcwqCZijQ2/Wi3BGHAI= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23 h1:dZ0/VyGgQdVGAss6Ju0dt5P0QltE0SFY5Woh6hbIfiQ= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/h2so5/here v0.0.0-20200815043652-5e14eb691fae h1:ghqI9EdSyyIL2iuOM9UIGVO7kEYQFVLKAUIFoOea5MY= +github.com/h2so5/here v0.0.0-20200815043652-5e14eb691fae/go.mod h1:Q+Ziz4FsuRTHql1UqcQ3iZwl9LcKpi7mVVgn20Rj+IU= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e h1:51xcRlSMBU5rhM9KahnJGfEsBPVPz3182TgFRowA8yY= +github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/spyzhov/ajson v0.9.6 h1:iJRDaLa+GjhCDAt1yFtU/LKMtLtsNVKkxqlpvrHHlpQ= +github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/starpkg/base v0.1.1 h1:AeNOEIyuyQJoQjgU+AxAE3+1ebgLlsF1HmA49tdTOtU= +github.com/starpkg/base v0.1.1/go.mod h1:0QLSawzBUCFLNk1jMRpzNs6AZH9SVpumd2J7WLOPrR0= +github.com/starpkg/qrcode v0.1.0 h1:VULD2XBVacMq0c2WxJgdG0d3k0Z4EOSaFdYdkOB7bME= +github.com/starpkg/qrcode v0.1.0/go.mod h1:itKiBubTuioyMM78bA+RTfwm/hH3EYG+W6NX0cscgh0= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +go.starlark.net v0.0.0-20260324133313-ffb3f39dd27a h1:w7OMj6r/AoxBpbfncRXaV18hjzIAFRytYaRILymmMRE= +go.starlark.net v0.0.0-20260324133313-ffb3f39dd27a/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello/app.star b/examples/hello/app.star new file mode 100644 index 0000000..7a142fa --- /dev/null +++ b/examples/hello/app.star @@ -0,0 +1,6 @@ +# This whole app is Starlark — the Go shell just embeds and runs it. +print("Hello from your embedded Starlark app 👋") + +squares = [n * n for n in range(1, 6)] +print("squares 1..5:", squares) +print("√1024 =", math.sqrt(1024)) diff --git a/examples/hello/main.go b/examples/hello/main.go new file mode 100644 index 0000000..dd86892 --- /dev/null +++ b/examples/hello/main.go @@ -0,0 +1,20 @@ +// hello is the smallest build-your-own shell: embed one .star file and run it +// with a couple of builtin modules. This is the entire program — the business +// logic lives in app.star, the Go side is just the shell. +package main + +import ( + _ "embed" + "log" + + "github.com/1set/starcli/kit" +) + +//go:embed app.star +var app string + +func main() { + if _, err := kit.Run(app, kit.WithModules("math")); err != nil { + log.Fatal(err) + } +} diff --git a/examples/qrcard/app.star b/examples/qrcard/app.star new file mode 100644 index 0000000..a24c2c0 --- /dev/null +++ b/examples/qrcard/app.star @@ -0,0 +1,5 @@ +# qrcode is a starpkg module the Go shell chose to wire in (see main.go). The +# shell's whole dependency tree is starbox + qrcode — nothing else. +qr = qrcode.encode("https://github.com/1set/starcli") +print("QR for the starcli repo (%d×%d modules):" % (qr.size, qr.size)) +print(qr.ascii()) diff --git a/examples/qrcard/main.go b/examples/qrcard/main.go new file mode 100644 index 0000000..8cb75ce --- /dev/null +++ b/examples/qrcard/main.go @@ -0,0 +1,21 @@ +// qrcard shows how a build-your-own shell wires a specific starpkg module: it +// imports just github.com/starpkg/qrcode and hands its loader to the kit. The +// turnkey starcli wires every starpkg module; this shell wires exactly one. +package main + +import ( + _ "embed" + "log" + + "github.com/1set/starcli/kit" + "github.com/starpkg/qrcode" +) + +//go:embed app.star +var app string + +func main() { + if _, err := kit.Run(app, kit.WithLoader(qrcode.ModuleName, qrcode.NewModule().LoadModule())); err != nil { + log.Fatal(err) + } +} diff --git a/kit/kit.go b/kit/kit.go new file mode 100644 index 0000000..6249a4c --- /dev/null +++ b/kit/kit.go @@ -0,0 +1,268 @@ +// Package kit is the reusable embed→wire→run core of starcli: it turns an +// embedded Starlark script plus a chosen set of modules and runtime knobs into a +// ready-to-run starbox runtime. +// +// It is the seam that keeps the whole project consistent. The standard turnkey +// starcli is one instance of kit wired with every starpkg module; a build-your- +// own shell is another instance wired with just the few modules it needs. Both +// go through the same construction path, so they behave identically. +// +// kit depends only on starbox (the pure host runtime) and starlet — never on any +// starpkg module — so a build-your-own shell pulls in only the modules it +// actually imports and wires itself. +// +// //go:embed app.star +// var app string +// +// func main() { +// if _, err := kit.Run(app, kit.WithModules("json", "math")); err != nil { +// log.Fatal(err) +// } +// } +package kit + +import ( + "io/fs" + + "github.com/1set/starbox" + "github.com/1set/starlet" + "go.uber.org/zap" +) + +// DefaultName is the box name used by the package-level Run / RunFS helpers when +// no explicit name is given. +const DefaultName = "app" + +// Kit holds the configuration for a starbox runtime. Build one with New and a set +// of Option values, then call Box, Run, or RunFile. The zero value is not usable; +// always go through New. +type Kit struct { + name string + moduleSet starbox.ModuleSetName + + // module wiring (see the resolution order note on Box). + moduleNames []string + loaders map[string]starlet.ModuleLoader + dynamicLoader starbox.DynamicModuleLoader + + policy *starbox.Policy + fsys fs.FS + logger *zap.SugaredLogger + printFunc starlet.PrintFunc + globals starlet.StringAnyMap + + globalReassign bool + recursion bool + outputConv bool + maxSteps uint64 + maxOutput uint +} + +// Option configures a Kit. +type Option func(*Kit) + +// New returns a Kit for a box with the given name, applying the options. The +// defaults match a clean, predictable runtime: the empty module set (nothing is +// loaded unless asked via WithModules / WithLoader / WithDynamicLoader), output +// conversion on (Run results come back as native Go values), and global +// reassignment, recursion, and execution budgets all off/unlimited. +func New(name string, opts ...Option) *Kit { + k := &Kit{ + name: name, + moduleSet: starbox.EmptyModuleSet, + loaders: map[string]starlet.ModuleLoader{}, + outputConv: true, + } + for _, opt := range opts { + opt(k) + } + return k +} + +// WithModules names modules to load. A name that is a starlet builtin (e.g. +// "json", "math") resolves on its own; any other name must be backed by a loader +// registered via WithLoader or WithDynamicLoader. +func WithModules(names ...string) Option { + return func(k *Kit) { k.moduleNames = append(k.moduleNames, names...) } +} + +// WithLoader registers an explicit loader for a module name — the way a build- +// your-own shell wires a specific starpkg module it imports, e.g. +// WithLoader(qrcode.ModuleName, qrcode.NewModule().LoadModule()). A registered +// loader is always loaded; it does not also need to appear in WithModules. +func WithLoader(name string, loader starlet.ModuleLoader) Option { + return func(k *Kit) { k.loaders[name] = loader } +} + +// WithDynamicLoader registers a single loader that resolves any requested module +// name on demand — the way the standard starcli exposes its whole module +// registry. Names listed in WithModules that are neither builtins nor explicit +// loaders are resolved through it. +func WithDynamicLoader(loader starbox.DynamicModuleLoader) Option { + return func(k *Kit) { k.dynamicLoader = loader } +} + +// WithModuleSet selects a predefined starbox module set (EmptyModuleSet by +// default). WithModules still adds individual modules on top of the set. +func WithModuleSet(set starbox.ModuleSetName) Option { + return func(k *Kit) { k.moduleSet = set } +} + +// WithPolicy installs a starbox load-gate policy (the host-only capability +// allowlist). Without it, every wired module is loadable. +func WithPolicy(p starbox.Policy) Option { + return func(k *Kit) { k.policy = &p } +} + +// WithFS sets the filesystem the script's load() statements and RunFile read +// from — typically an embed.FS of .star files. +func WithFS(fsys fs.FS) Option { + return func(k *Kit) { k.fsys = fsys } +} + +// WithGlobals injects host values as script globals. +func WithGlobals(g starlet.StringAnyMap) Option { + return func(k *Kit) { + if k.globals == nil { + k.globals = starlet.StringAnyMap{} + } + for key, val := range g { + k.globals[key] = val + } + } +} + +// WithGlobal injects a single host value as a script global. +func WithGlobal(key string, value interface{}) Option { + return func(k *Kit) { + if k.globals == nil { + k.globals = starlet.StringAnyMap{} + } + k.globals[key] = value + } +} + +// WithLogger routes the script's log module to the given zap logger. +func WithLogger(l *zap.SugaredLogger) Option { + return func(k *Kit) { k.logger = l } +} + +// WithPrintFunc overrides how the script's print() output is rendered. Without +// it, starbox's default (stdout) is used. +func WithPrintFunc(pf starlet.PrintFunc) Option { + return func(k *Kit) { k.printFunc = pf } +} + +// WithGlobalReassign toggles top-level global reassignment in the script dialect. +func WithGlobalReassign(on bool) Option { + return func(k *Kit) { k.globalReassign = on } +} + +// WithRecursion toggles recursion support in the script dialect. +func WithRecursion(on bool) Option { + return func(k *Kit) { k.recursion = on } +} + +// WithOutputConversion toggles whether Run results are converted to native Go +// values (on by default). Turn it off to keep raw Starlark values. +func WithOutputConversion(on bool) Option { + return func(k *Kit) { k.outputConv = on } +} + +// WithMaxSteps bounds a single run to n Starlark execution steps (0 = unlimited), +// a guard against runaway loops a wall-clock timeout cannot stop. +func WithMaxSteps(n uint64) Option { + return func(k *Kit) { k.maxSteps = n } +} + +// WithMaxOutputEntries caps the number of top-level result entries a run may +// produce (0 = unlimited). +func WithMaxOutputEntries(n uint) Option { + return func(k *Kit) { k.maxOutput = n } +} + +// Box constructs the configured *starbox.Starbox. Module names resolve in +// starbox's order — starlet builtins first, then explicit WithLoader entries, +// then the WithDynamicLoader fallback — so the same name list works whether a +// shell wires modules one by one or through a registry. +func (k *Kit) Box() (*starbox.Starbox, error) { + var box *starbox.Starbox + if k.policy != nil { + box = starbox.NewWithPolicy(k.name, *k.policy) + } else { + box = starbox.New(k.name) + } + + if k.logger != nil { + box.SetLogger(k.logger) + } + if k.fsys != nil { + box.SetFS(k.fsys) + } + box.SetMaxExecutionSteps(k.maxSteps) + box.SetMaxOutputEntries(k.maxOutput) + + mac := box.GetMachine() + mac.SetOutputConversionEnabled(k.outputConv) + if k.globalReassign { + mac.EnableGlobalReassign() + } else { + mac.DisableGlobalReassign() + } + if k.recursion { + mac.EnableRecursionSupport() + } else { + mac.DisableRecursionSupport() + } + + if k.printFunc != nil { + box.SetPrintFunc(k.printFunc) + } + + // Start from a clean module set, then wire exactly what was requested. + box.SetModuleSet(k.moduleSet) + if k.dynamicLoader != nil { + box.SetDynamicModuleLoader(k.dynamicLoader) + } + for name, loader := range k.loaders { + box.AddModuleLoader(name, loader) + } + if len(k.globals) > 0 { + box.AddKeyValues(k.globals) + } + if len(k.moduleNames) > 0 { + box.AddModulesByName(k.moduleNames...) + } + return box, nil +} + +// Run constructs the box and executes the given script source. +func (k *Kit) Run(script string) (starlet.StringAnyMap, error) { + box, err := k.Box() + if err != nil { + return nil, err + } + return box.Run(script) +} + +// RunFile constructs the box and executes a script file read from the configured +// filesystem (see WithFS). +func (k *Kit) RunFile(file string) (starlet.StringAnyMap, error) { + box, err := k.Box() + if err != nil { + return nil, err + } + return box.RunFile(file) +} + +// Run is the package-level one-liner for a build-your-own shell: it wires a +// default-named box from the options and runs the embedded script source. +func Run(script string, opts ...Option) (starlet.StringAnyMap, error) { + return New(DefaultName, opts...).Run(script) +} + +// RunFS is the package-level one-liner that runs entry out of fsys (typically an +// embed.FS of .star files), so a shell can ship a whole tree of scripts. +func RunFS(fsys fs.FS, entry string, opts ...Option) (starlet.StringAnyMap, error) { + return New(DefaultName, append([]Option{WithFS(fsys)}, opts...)...).RunFile(entry) +} diff --git a/kit/kit_test.go b/kit/kit_test.go new file mode 100644 index 0000000..7871db1 --- /dev/null +++ b/kit/kit_test.go @@ -0,0 +1,192 @@ +package kit_test + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + "testing/fstest" + + "github.com/1set/starbox" + "github.com/1set/starcli/kit" + "github.com/1set/starlet" + "go.starlark.net/starlark" + "go.uber.org/zap" +) + +// lifeLoader is a trivial custom module loader used to prove the WithLoader / +// WithDynamicLoader wiring without dragging in any starpkg module. +func lifeLoader() (starlark.StringDict, error) { + return starlark.StringDict{"answer": starlark.MakeInt(42)}, nil +} + +func TestKit(t *testing.T) { + tests := []struct { + name string + opts []kit.Option + script string + want map[string]string // result key -> fmt.Sprint(value) + }{ + { + name: "builtin module", + opts: []kit.Option{kit.WithModules("math")}, + script: `x = math.floor(3.7)`, + want: map[string]string{"x": "3"}, + }, + { + name: "inject global", + opts: []kit.Option{kit.WithGlobal("who", "world")}, + script: `greeting = "hi " + who`, + want: map[string]string{"greeting": "hi world"}, + }, + { + name: "explicit loader", + opts: []kit.Option{ + kit.WithLoader("life", lifeLoader), + kit.WithModules("life"), + }, + script: `load("life", "answer") +doubled = answer * 2`, + want: map[string]string{"doubled": "84"}, + }, + { + name: "dynamic loader", + opts: []kit.Option{ + kit.WithDynamicLoader(func(name string) (starlet.ModuleLoader, error) { + if name == "life" { + return lifeLoader, nil + } + return nil, fmt.Errorf("unknown module: %s", name) + }), + kit.WithModules("life"), + }, + script: `load("life", "answer") +tripled = answer * 3`, + want: map[string]string{"tripled": "126"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := kit.Run(tt.script, tt.opts...) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + for key, want := range tt.want { + if got := fmt.Sprint(out[key]); got != want { + t.Errorf("result[%q] = %q, want %q", key, got, want) + } + } + }) + } +} + +// TestRunFS proves a shell can ship a whole tree of .star files and run an entry +// point out of it (the embed.FS path). +func TestRunFS(t *testing.T) { + fsys := fstest.MapFS{ + "main.star": &fstest.MapFile{Data: []byte(`load("lib.star", "double") +result = double(21)`)}, + "lib.star": &fstest.MapFile{Data: []byte(`def double(n): + return n * 2`)}, + } + out, err := kit.RunFS(fsys, "main.star") + if err != nil { + t.Fatalf("RunFS() error = %v", err) + } + if got := fmt.Sprint(out["result"]); got != "42" { + t.Errorf("result = %q, want %q", got, "42") + } +} + +// TestOutputLimit proves the per-run output-entry cap is wired through Kit and +// surfaces starbox's typed error. +func TestOutputLimit(t *testing.T) { + _, err := kit.Run(`a = 1 +b = 2 +c = 3`, kit.WithMaxOutputEntries(2)) + var limitErr starbox.OutputLimitExceededError + if !errors.As(err, &limitErr) { + t.Fatalf("error = %v, want OutputLimitExceededError", err) + } +} + +// TestRunError proves a script failure is returned, not swallowed or panicked. +func TestRunError(t *testing.T) { + _, err := kit.Run(`fail("boom")`) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("error = %v, want one mentioning boom", err) + } +} + +// TestPolicyGate proves WithPolicy installs a load gate: a module outside the +// allowlist cannot be loaded even though a loader exists for it. +func TestPolicyGate(t *testing.T) { + allowed := starbox.Policy{Modules: starbox.ModuleAllow{Names: []string{"math"}}} + out, err := kit.Run(`v = math.floor(9.9)`, + kit.WithPolicy(allowed), kit.WithModules("math")) + if err != nil { + t.Fatalf("allowed module: Run() error = %v", err) + } + if got := fmt.Sprint(out["v"]); got != "9" { + t.Errorf("v = %q, want %q", got, "9") + } + + // json is not in the allowlist, so loading it must fail. + if _, err := kit.Run(`x = json.encode({})`, + kit.WithPolicy(allowed), kit.WithModules("json")); err == nil { + t.Fatal("denied module: expected an error, got nil") + } +} + +// TestKnobsAndOptions exercises the dialect, budget, globals, print, and logger +// options together: the script reassigns a top-level global and recurses, both of +// which only resolve when those dialect knobs are enabled. +func TestKnobsAndOptions(t *testing.T) { + var printed bytes.Buffer + logger := zap.NewNop().Sugar() + + out, err := kit.Run(`x = 1 +x = base + 1 +def fact(n): + if n <= 1: + return 1 + return n * fact(n - 1) +result = fact(5) +print("computed")`, + kit.WithModuleSet(starbox.EmptyModuleSet), + kit.WithGlobals(starlet.StringAnyMap{"base": 10}), + kit.WithGlobalReassign(true), + kit.WithRecursion(true), + kit.WithOutputConversion(false), + kit.WithMaxSteps(1_000_000), + kit.WithMaxOutputEntries(50), + kit.WithLogger(logger), + kit.WithPrintFunc(func(_ *starlark.Thread, msg string) { printed.WriteString(msg) }), + ) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !strings.Contains(printed.String(), "computed") { + t.Errorf("print output = %q, want it to contain %q", printed.String(), "computed") + } + if out["result"] == nil { + t.Error("result global missing") + } +} + +// TestBoxReusable proves New(...).Box() yields a configured box the caller can +// drive directly — the seam the standard starcli builds on. +func TestBoxReusable(t *testing.T) { + box, err := kit.New("demo", kit.WithModules("math")).Box() + if err != nil { + t.Fatalf("Box() error = %v", err) + } + out, err := box.Run(`v = math.pow(2, 10)`) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if got := fmt.Sprint(out["v"]); got != "1024" { + t.Errorf("v = %q, want %q", got, "1024") + } +}