From 0e653ba38a333df4f1bda8dc36982d6d1f169eaa Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Sun, 11 Oct 2026 10:33:07 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20sdk:=20run=20a=20Go=20exten?= =?UTF-8?q?sion=20from=20one=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: M02-T3 asks for sdk/extension, whose Main parses a call with the manifest, runs the handler of the command, and prints its answer by the kind of its result, and whose Check catches what Main would refuse and what breaks the catalog rules - Impact: a Go extension answers like a core command, with its own codes ordered as Output says and a report that needs a human on stdout with exit 78. On a signal, the SDK ends the handler's context, stops its programs, and keeps the handler's keys. The fixture hello proves it through ncly and run directly, and the test binary runs the scenarios of every extension folder Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/ncly/script_test.go | 73 ++++- cmd/ncly/scriptcmds_test.go | 20 +- docs/milestones/M02-sdk.md | 2 +- docs/north-star/architecture.md | 2 +- docs/north-star/contract.md | 6 +- internal/cli/dispatch.go | 51 +--- internal/cli/extension.go | 26 +- internal/cli/root.go | 24 +- justfile | 7 +- sdk/command/answer.go | 62 +++- sdk/command/command.go | 26 ++ sdk/command/line.go | 30 ++ sdk/contract/answer.go | 52 +++- sdk/contract/answercheck.go | 8 +- sdk/contract/codes.go | 75 ++++- sdk/contract/compat_test.go | 6 +- sdk/contract/verdict_test.go | 72 ++++- sdk/extension/extension.go | 276 ++++++++++++++++++ sdk/extension/extension_test.go | 99 +++++++ sdk/i18n/locales/en.toml | 8 + sdk/runner/runner.go | 16 +- sdk/runner/runner_test.go | 20 ++ .../extensions/hello/cmd/ncly-hello/main.go | 9 + testdata/extensions/hello/hello.go | 125 ++++++++ testdata/extensions/hello/hello_test.go | 13 + testdata/extensions/hello/locales/en.toml | 89 ++++++ testdata/extensions/hello/manifest.toml | 64 ++++ .../hello/testdata/script/sdk_extension.txtar | 137 +++++++++ 28 files changed, 1242 insertions(+), 156 deletions(-) create mode 100644 sdk/extension/extension.go create mode 100644 sdk/extension/extension_test.go create mode 100644 testdata/extensions/hello/cmd/ncly-hello/main.go create mode 100644 testdata/extensions/hello/hello.go create mode 100644 testdata/extensions/hello/hello_test.go create mode 100644 testdata/extensions/hello/locales/en.toml create mode 100644 testdata/extensions/hello/manifest.toml create mode 100644 testdata/extensions/hello/testdata/script/sdk_extension.txtar diff --git a/cmd/ncly/script_test.go b/cmd/ncly/script_test.go index e10c3b34..12c76e46 100644 --- a/cmd/ncly/script_test.go +++ b/cmd/ncly/script_test.go @@ -2,33 +2,71 @@ package main import ( "fmt" + "io/fs" "os" "path/filepath" "testing" "time" + "github.com/pascalandy/nicely/sdk/extension" "github.com/pascalandy/nicely/sdk/runner" + hello "github.com/pascalandy/nicely/testdata/extensions/hello" "github.com/rogpeppe/go-internal/testscript" ) func TestMain(m *testing.M) { - testscript.Main(m, map[string]func(){"ncly": testNcly, "closedpipe": closedPipe, "exitcode": exitCode}) + testscript.Main(m, map[string]func(){ + "ncly": testNcly, + "closedpipe": closedPipe, + "exitcode": exitCode, + "hello-external": func() { shortCleanup(); extension.Main(hello.Extension) }, + }) } -// testNcly runs ncly with 4 seconds of cleanup instead of 15, so that a -// scenario reaches the SIGKILL of cleanup quickly. The drain limit keeps its -// 2 seconds, so the scenarios still tell the two clocks apart. +// fixtures holds the Go fixture extensions of testdata/extensions/, by +// domain. The setup of a fixture's scenarios installs it in the data folder. +var fixtures = map[string]extension.Extension{"hello": hello.Extension} + +// testNcly runs ncly with a short cleanup. func testNcly() { - runner.Cleanup = 4 * time.Second + shortCleanup() main() } +// shortCleanup gives cleanup 4 seconds instead of 15, so that a scenario +// reaches the SIGKILL of cleanup quickly. The drain limit keeps its 2 +// seconds, so the scenarios still tell the two clocks apart. +func shortCleanup() { runner.Cleanup = 4 * time.Second } + +// TestScripts runs core's scenarios, then those of each extension and of +// each fixture extension, which keep theirs in their own folder. func TestScripts(t *testing.T) { + root := filepath.Join("..", "..") + t.Run("core", func(t *testing.T) { runScripts(t, filepath.Join(root, "testdata", "script"), setupHome) }) + extensions, _ := filepath.Glob(filepath.Join(root, "extensions", "*", "testdata", "script")) + fixtureDirs, _ := filepath.Glob(filepath.Join(root, "testdata", "extensions", "*", "testdata", "script")) + for _, dir := range append(extensions, fixtureDirs...) { + domain := filepath.Base(filepath.Dir(filepath.Dir(dir))) + t.Run(domain, func(t *testing.T) { + runScripts(t, dir, func(env *testscript.Env) error { + if err := setupHome(env); err != nil { + return err + } + if e, isFixture := fixtures[domain]; isFixture { + return install(env, domain, e) + } + return nil + }) + }) + } +} + +func runScripts(t *testing.T, dir string, setup func(*testscript.Env) error) { testscript.Run(t, testscript.Params{ - Dir: filepath.Join("..", "..", "testdata", "script"), + Dir: dir, RequireExplicitExec: true, RequireUniqueNames: true, - Setup: setupHome, + Setup: setup, Condition: condition, Cmds: map[string]func(*testscript.TestScript, bool, []string){ "exits": cmdExits, @@ -43,6 +81,27 @@ func TestScripts(t *testing.T) { }) } +// install puts a Go fixture in the data folder as an external extension: +// the script ncly- that runs it, with its manifest and catalogs +// beside it. The script runs the test binary under a name other than +// ncly-, so the fixture never runs as a bundled extension here. +func install(env *testscript.Env, domain string, e extension.Extension) error { + dir := filepath.Join(env.WorkDir, ".local", "share", "nicely", "extensions") + executable := filepath.Join(dir, "ncly-"+domain) + locales, err := fs.Sub(e.Locales, "locales") + if err == nil { + err = os.CopyFS(executable+".locales", locales) + } + if err == nil { + err = os.WriteFile(executable+".toml", e.Manifest, 0o644) + } + if err == nil { + err = os.WriteFile(executable, []byte("#!/bin/sh\nexec "+domain+"-external \"$@\"\n"), 0o755) + } + env.Setenv("DATA", dir) + return err +} + // setupHome points HOME and the XDG folders at $WORK, so ncly never touches // the host's own config, data, state, or cache. func setupHome(env *testscript.Env) error { diff --git a/cmd/ncly/scriptcmds_test.go b/cmd/ncly/scriptcmds_test.go index 457ec5f4..e2c8ae70 100644 --- a/cmd/ncly/scriptcmds_test.go +++ b/cmd/ncly/scriptcmds_test.go @@ -232,7 +232,9 @@ func shellStatus(state *os.ProcessState) int { } var ( - marked = regexp.MustCompile(`(?s)⟦.*?⟧`) + // marked matches an innermost marked text, since a translated text can + // hold another, such as a greeting inside a sentence. + marked = regexp.MustCompile(`⟦[^⟦⟧]*⟧`) example = regexp.MustCompile(`(?m)^\s*\$ .*$`) letter = regexp.MustCompile(`\p{L}`) version = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)*$`) @@ -251,8 +253,12 @@ func cmdPseudo(ts *testscript.TestScript, neg bool, args []string) { if !marked.MatchString(text) { ts.Fatalf("pseudo: no marked text on %s", args[0]) } + text = example.ReplaceAllString(text, "") + for marked.MatchString(text) { + text = marked.ReplaceAllString(text, " ") + } var stray []string - for _, word := range strings.Fields(marked.ReplaceAllString(example.ReplaceAllString(text, ""), " ")) { + for _, word := range strings.Fields(text) { word = strings.Trim(word, `.,:;!?()"'`) switch { case !letter.MatchString(word), @@ -271,11 +277,15 @@ func cmdPseudo(ts *testscript.TestScript, neg bool, args []string) { } } -// isCode reports whether a word is a registered error or warning code, the -// only uppercase words that stay untranslated. +// codeForm is the form of an extension's code, such as HELLO_GRUMPY. +var codeForm = regexp.MustCompile(`^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$`) + +// isCode reports whether a word is a code, the only uppercase words that +// stay untranslated: one of core's, or one of the form of an extension's, +// whose set is open. func isCode(word string) bool { _, isError := contract.Code(word).Exit() - return isError || contract.Code(word).IsWarning() + return isError || contract.Code(word).IsWarning() || codeForm.MatchString(word) } // cmdWaitfile waits until a file exists, such as the process ID that a diff --git a/docs/milestones/M02-sdk.md b/docs/milestones/M02-sdk.md index affc0430..c2e2aa89 100644 --- a/docs/milestones/M02-sdk.md +++ b/docs/milestones/M02-sdk.md @@ -24,7 +24,7 @@ The SDK is the Go implementation of the contract between core and its extensions |---|---|---|---|---| | M02-T1 | Shared packages in sdk | agent | — | done | | M02-T2 | Import boundary | agent | M02-T1 | done | -| M02-T3 | Extension entry point | agent | M02-T1 | todo | +| M02-T3 | Extension entry point | agent | M02-T1 | done | | M02-T4 | Bundled transport | agent | M02-T3 | todo | ### M02-T1 Shared packages in sdk diff --git a/docs/north-star/architecture.md b/docs/north-star/architecture.md index b11fad77..ec56acd4 100644 --- a/docs/north-star/architecture.md +++ b/docs/north-star/architecture.md @@ -75,7 +75,7 @@ The other folders: - `cmd/ncly/`: the entry point, which passes core the bundled extensions in an explicit list, and runs one of them when started under its name - `internal/specdoc/`: reads the tables of the north-star docs for the tests that compare code and spec - `testdata/script/`: core's testscript scenarios. An extension keeps its own in `extensions//testdata/script/`, and the test binary of `cmd/ncly` runs both -- `testdata/extensions/hello/`: the Go fixture extension that proves the SDK, laid out as a first-party extension. `./...` skips it, so `just lint` names it +- `testdata/extensions/hello/`: the Go fixture extension that proves the SDK, laid out as a first-party extension. `./...` skips it, so `just lint` and `just test` name it - `tools/`: a separate module that pins the dev tools and holds the plan checker behind `just next` and `just test` ## Domains and commands diff --git a/docs/north-star/contract.md b/docs/north-star/contract.md index eb6b38a6..6d3a100c 100644 --- a/docs/north-star/contract.md +++ b/docs/north-star/contract.md @@ -381,17 +381,17 @@ func Check(e Extension) error `Main` parses the call with the manifest, prints the help of a group, and calls the handler of the path. It prints the result by its kind, from the command's `result`: - `object` and `batch`: in human mode, the handler writes its text to `Stdout`. With `--json`, it writes nothing there, and the SDK prints the answer from the outcome -- `report`: the handler returns its findings in the outcome's data, and marks the outcome when a finding needs a human. The SDK answers on stdout with `ok` and exit 0, or `false` and exit 78, without `errors`, as [Output](#output) says. Core's doctor uses the same outcome from M05 +- `report`: in human mode, the handler writes its report to `Stdout`. With `--json`, the SDK prints the findings from the outcome's data on stdout, in an answer without `errors`, as [Output](#output) says. In both modes, the handler returns its findings in that data, and sets the outcome's `HumanNeeded` when a finding needs a human, which makes the exit code 78 instead of 0, and `ok` false with `--json`. Core's doctor uses the same outcome from M05 - `text`: the handler writes its text to `Stdout`, whatever `--json` says -A failure of the command itself prints as an ordinary failure for every kind: the error block, or the answer on stderr with `--json`. On Ctrl-C or SIGTERM, the SDK ends `ctx`, stops the extension's programs as [Programs that ncly runs](#programs-that-ncly-runs) defines, and sets the outcome's signal, so a handler returns what it has. A handler that has not returned when the 15 seconds of cleanup end gets no more time: the SDK answers `INTERRUPTED` or `TERMINATED` alone and exits. Under `ncly`, core's own 15 seconds govern, and either path gives the same answer, because both carry the signal's code and no data key. Run directly, `ncly-` also parses the global flags and the ordinary syntax that core accepts, because canonical arguments are a subset of it. `Check` reports a manifest that breaks the rules of [Manifest](#manifest), a command that runs without a handler, a handler for no such command, and a catalog that breaks the catalog rules below. Every Go extension has a test that calls it. `Main` checks the manifest and the handlers when it starts and answers `RUNTIME` for those faults, while a missing translation falls back to `en` at run time, as [Text of an extension](#text-of-an-extension) says. +A failure of the command itself prints as an ordinary failure for every kind: the error block, or the answer on stderr with `--json`. On Ctrl-C or SIGTERM, the SDK ends `ctx`, stops the extension's programs as [Programs that ncly runs](#programs-that-ncly-runs) defines, and sets the outcome's signal, so a handler returns what it has. A handler that has not returned once the 15 seconds of cleanup and the 2 seconds of the drain limit have passed, the time that the SIGKILL of cleanup gives its programs to end, gets no more time: the SDK answers `INTERRUPTED` or `TERMINATED` alone and exits. Under `ncly`, core's own 15 seconds govern, and either path gives the same answer, because both carry the signal's code and no data key. Run directly, `ncly-` also parses the global flags and the ordinary syntax that core accepts, because canonical arguments are a subset of it, and `--version` prints the version of the module that built it. `Check` reports a manifest that breaks the rules of [Manifest](#manifest), a command that runs without a handler, a handler for no such command, and a catalog that breaks the catalog rules below. Every Go extension has a test that calls it. `Main` checks the manifest and the handlers when it starts and answers `RUNTIME` for those faults, while a missing translation falls back to `en` at run time, as [Text of an extension](#text-of-an-extension) says. The same value serves both transports, so they cannot drift, as [D051](decisions/D051-bundled-child-process.md) decides: - **External:** the extension's `main` calls `extension.Main`. Installing it writes the embedded manifest and catalogs beside the executable, as `ncly-.toml` and `ncly-.locales/` - **Bundled:** `cmd/ncly` passes the value to core in an explicit list, never through an `init` side effect. Core reads its embedded manifest and catalogs, validates its call, then starts the `ncly` binary itself as the extension's program, with `ncly-` as its first argument, `argv[0]`, through the program runner. Started under the name of a bundled extension, `ncly` runs that extension's `Main` instead of core, as busybox picks its applet from its name. A bundled extension therefore gets the canonical arguments, the environment, the process group, the cancellation, and the answer check of an external one -The SDK accepts the error codes that the manifest declares, and every warning code that starts with the domain and an underscore, beside core's codes, so the verdict and the answer follow [Output](#output). The SDK's catalog holds the text that the SDK renders: help pages, global flags, usage errors, error labels, outcome summaries, and size units. An extension's printer takes an ID that starts with its domain and a dot from the extension's catalogs, and any other ID from the SDK's, so an extension never overrides the SDK's text. Core's printer takes an ID from core's catalog when that catalog holds it, and from the SDK's otherwise. The catalog rules are these: every entry has a description, every language holds exactly the IDs of `en`, an extension's IDs start with its domain and a dot, and core's IDs differ from the SDK's. `Check` applies them to an extension, and one parity test applies them to every catalog folder of the repository, core's, the SDK's, and each extension's. +The SDK accepts the error codes that the manifest declares, and every warning code that starts with the domain and an underscore, beside core's codes, so the verdict and the answer follow [Output](#output). The SDK's catalog holds the text that the SDK renders: help pages, global flags, usage errors, error labels, outcome summaries, size units, and the error of an extension that does not match its own manifest. An extension's printer takes an ID that starts with its domain and a dot from the extension's catalogs, and any other ID from the SDK's, so an extension never overrides the SDK's text. Core's printer takes an ID from core's catalog when that catalog holds it, and from the SDK's otherwise. The catalog rules are these: every entry has a description, every language holds exactly the IDs of `en`, an extension's IDs start with its domain and a dot, and core's IDs differ from the SDK's. `Check` applies them to an extension, and one parity test applies them to every catalog folder of the repository, core's, the SDK's, and each extension's. ## Operations diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go index 09a2a31b..b8bd3270 100644 --- a/internal/cli/dispatch.go +++ b/internal/cli/dispatch.go @@ -64,17 +64,17 @@ func (c *call) run(p *i18n.Printer, g command.Globals, stdout, stderr *os.File) data := map[string]any{"Domain": c.extension.domain, "Exit": result.Exit} switch { case err != nil: - return command.Report(p, g, protocolFailure(p, "extension.start_failed", data), stdout, stderr) + return command.Report(p, g, contract.Core, protocolFailure(p, "extension.start_failed", data), stdout, stderr) case result.Signal != nil: // The extension's own text already told the human, unless its exit // code says otherwise. - stopped := contract.Outcome{Signal: stopSignal(result.Signal)} - if verdict := contract.Decide(stopped); result.Exit == int(verdict.Exit) { + stopped := contract.Outcome{Signal: contract.StopSignal(result.Signal)} + if verdict := contract.Core.Decide(stopped); result.Exit == int(verdict.Exit) { return int(verdict.Exit) } - return command.Report(p, g, stopped, stdout, stderr) + return command.Report(p, g, contract.Core, stopped, stdout, stderr) case !contract.ExitCode(result.Exit).Known(): - return command.Report(p, g, protocolFailure(p, "extension.answer_exit", data), stdout, stderr) + return command.Report(p, g, contract.Core, protocolFailure(p, "extension.answer_exit", data), stdout, stderr) } return result.Exit } @@ -90,7 +90,7 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global result, err := runner.Run(ctx, program) if err != nil { data := map[string]any{"Domain": c.extension.domain} - return command.Report(p, g, protocolFailure(p, "extension.start_failed", data), stdout, stderr) + return command.Report(p, g, contract.Core, protocolFailure(p, "extension.start_failed", data), stdout, stderr) } reply := contract.Reply{Exit: result.Exit, Stdout: held.Bytes(), Last: lines.finish()} answer, err := contract.CheckAnswer(c.extension.manifest, c.kind, reply) @@ -111,11 +111,11 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global case result.Signal != nil && err == nil: // The first signal decides the verdict, and a valid answer keeps its // keys, as Answer check in contract.md asks. - return reportKept(p, g, contract.Outcome{Signal: stopSignal(result.Signal)}, answer, stdout, stderr) + return reportKept(p, g, contract.Outcome{Signal: contract.StopSignal(result.Signal)}, answer, stdout, stderr) case result.Signal != nil: - return command.Report(p, g, contract.Outcome{Signal: stopSignal(result.Signal)}, stdout, stderr) + return command.Report(p, g, contract.Core, contract.Outcome{Signal: contract.StopSignal(result.Signal)}, stdout, stderr) case errors.As(err, &rule): - return command.Report(p, g, protocolFailure(p, rule.Rule, rule.Data), stdout, stderr) + return command.Report(p, g, contract.Core, protocolFailure(p, rule.Rule, rule.Data), stdout, stderr) } if _, err := answerOut.Write(answer.Line); err != nil { if answerOut == stdout { @@ -126,23 +126,15 @@ func (c *call) runChecked(ctx context.Context, p *i18n.Printer, g command.Global return result.Exit } -// stopSignal names the signal that stopped an extension in the contract. -func stopSignal(sig os.Signal) contract.Signal { - if sig == os.Interrupt { - return contract.Interrupt - } - return contract.Terminate -} - // reportKept answers the failure that o decides with the other keys of an // accepted answer, because the work that the answer describes happened, as // Output in contract.md asks. It returns the exit code of o, or 1 when // stderr refuses the answer too. func reportKept(p *i18n.Printer, g command.Globals, o contract.Outcome, answer contract.ExtensionAnswer, stdout, stderr io.Writer) int { - a := contract.Finish(o, command.Summarizer(p)) + a := contract.Core.Finish(o, command.Summarizer(p)) line, err := answer.Failed(a.Errors()[0]) if err != nil { - return command.Report(p, g, o, stdout, stderr) + return command.Report(p, g, contract.Core, o, stdout, stderr) } if _, err := stderr.Write(append(line, '\n')); err != nil { return int(contract.ExitRuntime) @@ -196,21 +188,10 @@ func (h *heldLines) finish() []byte { // extensionVars carries the resolved global flags and the identity of ncly // to an extension. An empty value removes the variable. func extensionVars(g command.Globals) map[string]string { - on := func(set bool) string { - if set { - return "1" - } - return "" - } self, _ := os.Executable() - return map[string]string{ - "NCLY_JSON": on(g.JSON), - "NCLY_NO_INPUT": on(g.NoInput), - "NCLY_VERBOSE": on(g.Verbose), - "NO_COLOR": on(g.NoColor), - "NCLY_LANG": i18n.Requested(g.Lang), - "NCLY_VERSION": releaseVersion(), - "NCLY_CONTRACT_VERSION": strconv.Itoa(contract.Version), - "NCLY_BIN": self, - } + vars := g.Vars() + vars["NCLY_VERSION"] = releaseVersion() + vars["NCLY_CONTRACT_VERSION"] = strconv.Itoa(contract.Version) + vars["NCLY_BIN"] = self + return vars } diff --git a/internal/cli/extension.go b/internal/cli/extension.go index 4199ab6d..590c5b0e 100644 --- a/internal/cli/extension.go +++ b/internal/cli/extension.go @@ -162,27 +162,11 @@ func addExtensions(root *cobra.Command, p *i18n.Printer, lang string, extensions if e.broken != nil { continue } - text := e.catalogs.Printer(lang) - commands := map[string]*cobra.Command{} - // A parent comes before its children once the paths are sorted by length. - declarations := slices.Clone(e.manifest.Commands) - slices.SortStableFunc(declarations, func(a, b contract.Command) int { return len(a.Path) - len(b.Path) }) - for _, d := range declarations { - var run func(*cobra.Command, []string) error - if e.manifest.Runs(d) { - run = func(c *cobra.Command, args []string) error { - return &call{extension: e, kind: d.Result.Kind, args: canonicalArgs(d, c, args)} - } + domain := command.AddManifest(root, p, e.catalogs.Printer(lang), e.manifest, func(d contract.Command) func(*cobra.Command, []string) error { + return func(c *cobra.Command, args []string) error { + return &call{extension: e, kind: d.Result.Kind, args: canonicalArgs(d, c, args)} } - c := command.Build(p, text, d, run) - path := strings.Join(d.Path, " ") - commands[path] = c - if len(d.Path) == 1 { - c.GroupID = extensionGroup - root.AddCommand(c) - continue - } - commands[strings.Join(d.Path[:len(d.Path)-1], " ")].AddCommand(c) - } + }) + domain.GroupID = extensionGroup } } diff --git a/internal/cli/root.go b/internal/cli/root.go index d0fb04df..c127b18a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,11 +2,11 @@ package cli import ( + "cmp" "errors" "fmt" "os" "os/signal" - "runtime/debug" "slices" "strings" "syscall" @@ -95,17 +95,7 @@ func Main(args []string, stdout, stderr *os.File) int { if errors.As(err, &extensionCall) { return extensionCall.run(p, g, stdout, stderr) } - if out.Err() != nil { - err = &command.Failure{Outcome: command.WriteFailure(p)} - } - if err == nil { - return 0 - } - var failed *command.Failure - if !errors.As(err, &failed) { - failed = command.Fail(contract.Problem{Code: contract.UsageInvalid, Message: p.T("usage.invalid"), Hint: "ncly --help"}) - } - return command.Report(p, g, failed.Outcome, stdout, stderr) + return command.Finish(p, g, contract.Core, out, err, "ncly --help", stdout, stderr) } // newRoot builds the command tree, with the commands of each valid @@ -183,12 +173,4 @@ func brokenTarget(extensions []*extension, words []string) *extension { // releaseVersion prefers the linked version, then the version that Go stamps // from the module or the VCS, and finally a development marker. -func releaseVersion() string { - if version != "" { - return version - } - if info, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(info.Main.Version, "v") { - return info.Main.Version - } - return "v0.0.0-dev" -} +func releaseVersion() string { return cmp.Or(version, command.ModuleVersion()) } diff --git a/justfile b/justfile index 10fc2f9a..27eba7fd 100644 --- a/justfile +++ b/justfile @@ -28,7 +28,8 @@ test *args: set -Eeuo pipefail # A hook or a caller can export GIT_DIR. Fixture commits must see a normal repository. while IFS= read -r git_variable; do unset "${git_variable}"; done < <(git rev-parse --local-env-vars) - go test ./... {{ args }} + # ./... skips testdata/, so the Go fixture extensions are named. + go test ./... ./testdata/extensions/... {{ args }} # Go's test cache never rechecks a file outside the tools module, such as the docs or the release workflow, so these tests run uncached. (cd tools && go test -count=1 ./cmd/... {{ args }}) bash scripts/release-check-test.sh @@ -37,13 +38,13 @@ test *args: # Lint the Go code [group('checks')] lint: - {{ golangci }} run ./... + {{ golangci }} run ./... ./testdata/extensions/... cd tools && go tool golangci-lint run ./cmd/... # Fail when a Go file needs formatting [group('checks')] fmt-check: - {{ golangci }} fmt --diff + {{ golangci }} fmt --diff ./... ./testdata/extensions/... cd tools && go tool golangci-lint fmt --diff # Check the release scripts and the dormant manual workflow diff --git a/sdk/command/answer.go b/sdk/command/answer.go index dfe688fb..5e0d0050 100644 --- a/sdk/command/answer.go +++ b/sdk/command/answer.go @@ -1,9 +1,11 @@ package command import ( + "errors" "fmt" "io" "os" + "sync" "github.com/pascalandy/nicely/sdk/contract" "github.com/pascalandy/nicely/sdk/i18n" @@ -11,11 +13,15 @@ import ( ) // Output records the first failed write to a file, so lost output never -// reports success. Beyond Write, it keeps only Fd, for terminal detection, -// so that no other method of the file can write around Write. +// reports success. Beyond Write, it keeps Fd, for terminal detection, and +// File, for a program that writes to the file itself. Write and Err are safe +// for concurrent use, because a handler that outlives its time may still +// write while the extension answers. type Output struct { file *os.File - err error + // mu guards err alone, so a write that blocks never delays Err. + mu sync.Mutex + err error } // NewOutput wraps a file, such as stdout. @@ -23,16 +29,29 @@ func NewOutput(file *os.File) *Output { return &Output{file: file} } func (o *Output) Write(b []byte) (int, error) { n, err := o.file.Write(b) - if err != nil && o.err == nil { - o.err = err + if err != nil { + o.mu.Lock() + if o.err == nil { + o.err = err + } + o.mu.Unlock() } return n, err } func (o *Output) Fd() uintptr { return o.file.Fd() } +// File returns the file, for a program that the runner starts, which then +// sees a terminal when the file is one. The program reports its own failed +// writes through its exit code. +func (o *Output) File() *os.File { return o.file } + // Err returns the first write error, or nil. -func (o *Output) Err() error { return o.err } +func (o *Output) Err() error { + o.mu.Lock() + defer o.mu.Unlock() + return o.err +} // Failure carries the outcome of a command that failed, as the error that // the command returns. @@ -48,8 +67,8 @@ func Fail(problems ...contract.Problem) *Failure { // Report prints an outcome and returns its exit code. An answer that // cannot be written is a runtime failure, whatever the verdict: when stderr // itself refuses it, the exit code is all that remains to tell. -func Report(p *i18n.Printer, g Globals, o contract.Outcome, stdout, stderr io.Writer) int { - a := contract.Finish(o, Summarizer(p)) +func Report(p *i18n.Printer, g Globals, codes contract.Registry, o contract.Outcome, stdout, stderr io.Writer) int { + a := codes.Finish(o, Summarizer(p)) var err error if g.JSON { err = a.WriteJSON(stdout, stderr) @@ -63,11 +82,36 @@ func Report(p *i18n.Printer, g Globals, o contract.Outcome, stdout, stderr io.Wr _, err = fmt.Fprint(tui.Output(stderr, g.NoColor), tui.Problems(a.Errors(), a.Warnings(), label, p.T("error.hint"))) } if err != nil { + if g.JSON && len(a.Errors()) == 0 { + // The answer belonged on stdout, so stderr may still take the + // failure to write it, with the keys that still apply. + lost := o + lost.Errors, lost.HumanNeeded = WriteFailure(p).Errors, false + return Report(p, g, codes, lost, stdout, stderr) + } return int(contract.ExitRuntime) } return int(a.Exit) } +// Finish returns the exit code of a command line whose run ended with err, +// after printing its failure: RUNTIME when out lost a write, the failure +// that err carries, or USAGE_INVALID with hint for any other error of the +// parser. +func Finish(p *i18n.Printer, g Globals, codes contract.Registry, out *Output, err error, hint string, stdout, stderr io.Writer) int { + if out.Err() != nil { + err = &Failure{Outcome: WriteFailure(p)} + } + if err == nil { + return int(contract.ExitOK) + } + var failed *Failure + if !errors.As(err, &failed) { + failed = Fail(contract.Problem{Code: contract.UsageInvalid, Message: p.T("usage.invalid"), Hint: hint}) + } + return Report(p, g, codes, failed.Outcome, stdout, stderr) +} + // WriteFailure is the outcome of an answer that could not be written. func WriteFailure(p *i18n.Printer) contract.Outcome { return contract.Outcome{Errors: []contract.Problem{{Code: contract.Runtime, Message: p.T("output.write_failed"), Hint: p.T("output.write_failed_hint")}}} @@ -83,7 +127,7 @@ var summaries = map[contract.Code]struct{ message, hint string }{ } // Summarizer returns the leading error of an outcome that no single failure -// explains, in the language of p, as contract.Finish asks. +// explains, in the language of p, as Registry.Finish asks. func Summarizer(p *i18n.Printer) func(contract.Code) contract.Problem { return func(code contract.Code) contract.Problem { s := summaries[code] diff --git a/sdk/command/command.go b/sdk/command/command.go index 17c803d1..a4db3d2d 100644 --- a/sdk/command/command.go +++ b/sdk/command/command.go @@ -6,6 +6,7 @@ package command import ( "errors" + "slices" "strings" "github.com/pascalandy/nicely/sdk/contract" @@ -61,6 +62,31 @@ func Build(p, text *i18n.Printer, d contract.Command, run func(*cobra.Command, [ return c } +// AddManifest adds the commands that a manifest declares under root, each +// parent before its children, and returns the command of its domain. text +// renders the manifest's catalog IDs. run returns what each command that +// runs does with its call, and a command for which it returns nil prints +// its help. +func AddManifest(root *cobra.Command, p, text *i18n.Printer, m contract.Manifest, run func(contract.Command) func(*cobra.Command, []string) error) *cobra.Command { + commands := map[string]*cobra.Command{} + declarations := slices.Clone(m.Commands) + slices.SortStableFunc(declarations, func(a, b contract.Command) int { return len(a.Path) - len(b.Path) }) + for _, d := range declarations { + var r func(*cobra.Command, []string) error + if m.Runs(d) { + r = run(d) + } + c := Build(p, text, d, r) + commands[strings.Join(d.Path, " ")] = c + if parent, found := commands[strings.Join(d.Path[:len(d.Path)-1], " ")]; found { + parent.AddCommand(c) + } else { + root.AddCommand(c) + } + } + return commands[m.Domain] +} + // AddGlobalFlags gives a root command the global flags of the contract, and // turns every parser error into USAGE_INVALID. func AddGlobalFlags(root *cobra.Command, p *i18n.Printer) { diff --git a/sdk/command/line.go b/sdk/command/line.go index 4267cbcf..fc3ec42e 100644 --- a/sdk/command/line.go +++ b/sdk/command/line.go @@ -3,10 +3,12 @@ package command import ( "cmp" "os" + "runtime/debug" "strconv" "strings" "github.com/pascalandy/nicely/sdk/config" + "github.com/pascalandy/nicely/sdk/i18n" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -123,6 +125,15 @@ func (line Line) Version() bool { return on } +// ModuleVersion returns the version that Go stamped from the module or the +// VCS of the build, or a development marker. +func ModuleVersion() string { + if info, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(info.Main.Version, "v") { + return info.Main.Version + } + return "v0.0.0-dev" +} + // Globals holds the resolved global flags. A flag wins over its variable. type Globals struct { JSON bool @@ -133,6 +144,25 @@ type Globals struct { Lang string } +// Vars returns the variables that carry the resolved global flags to a +// program, as Global flags in contract.md names them. An empty value means +// that the variable is unset. +func (g Globals) Vars() map[string]string { + on := func(set bool) string { + if set { + return "1" + } + return "" + } + return map[string]string{ + "NCLY_JSON": on(g.JSON), + "NCLY_NO_INPUT": on(g.NoInput), + "NCLY_VERBOSE": on(g.Verbose), + "NO_COLOR": on(g.NoColor), + "NCLY_LANG": i18n.Requested(g.Lang), + } +} + // Resolve applies the precedence of contract.md. It reads the config // only when neither --lang nor NCLY_LANG sets the language. func Resolve(line Line) Globals { diff --git a/sdk/contract/answer.go b/sdk/contract/answer.go index 941e173f..c52b1a17 100644 --- a/sdk/contract/answer.go +++ b/sdk/contract/answer.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "maps" + "os" "slices" ) @@ -52,6 +53,15 @@ const ( Terminate ) +// StopSignal names the signal that stopped an invocation: Interrupt for +// Ctrl-C, and Terminate for any other, such as SIGTERM. +func StopSignal(sig os.Signal) Signal { + if sig == os.Interrupt { + return Interrupt + } + return Terminate +} + // Item is the result of one input of a batch. type Item struct { // Data holds the keys of the item, such as url. It marshals to an object. @@ -71,6 +81,10 @@ type Outcome struct { Warnings []Problem Effect Effect Signal Signal + // HumanNeeded marks a report whose findings need a human, such as a + // failed check of doctor. The report answers ok false and exit 78 on + // stdout, without errors. + HumanNeeded bool } // Verdict is the exit code of an outcome and the code of its leading error. @@ -81,7 +95,7 @@ type Verdict struct { // Decide computes the verdict of an outcome. The order in which failures // arrived never changes it. -func Decide(o Outcome) Verdict { +func (r Registry) Decide(o Outcome) Verdict { switch o.Signal { case Interrupt: return Verdict{ExitInterrupted, Interrupted} @@ -96,19 +110,25 @@ func Decide(o Outcome) Verdict { } } if len(o.Errors) == 0 && !itemFailed { + if o.HumanNeeded { + return Verdict{ExitHuman, ""} + } return Verdict{ExitOK, ""} } lead := Temporary for _, p := range o.Errors { - if p.Code.rank() < lead.rank() { + if r.rank(p.Code) < r.rank(lead) { lead = p.Code } } - if lead != Temporary { - exit, _ := lead.Exit() + // A failed item makes the verdict the batch's, as Output defines, and + // the command's own errors follow its lead, except a signal's, which + // takes precedence there too. + if lead != Temporary && (!itemFailed || lead == Interrupted || lead == Terminated) { + exit, _ := r.exit(lead) return Verdict{exit, lead} } - if allTemporary && o.Effect.safeToRepeat() { + if lead == Temporary && allTemporary && o.Effect.safeToRepeat() { return Verdict{ExitTemporary, Temporary} } return Verdict{ExitRuntime, Runtime} @@ -133,9 +153,9 @@ func (a Answer) Warnings() []Problem { return a.warnings } // Finish decides an outcome and puts an error with the verdict's code first. // When no error carries that code, as for an incomplete batch or a signal, // summarize supplies one in the active language. -func Finish(o Outcome, summarize func(Code) Problem) Answer { - mustRegister(o) - v := Decide(o) +func (r Registry) Finish(o Outcome, summarize func(Code) Problem) Answer { + r.mustRegister(o) + v := r.Decide(o) errs := slices.Clone(o.Errors) if v.Lead != "" { if i := slices.IndexFunc(errs, func(p Problem) bool { return p.Code == v.Lead }); i >= 0 { @@ -149,11 +169,12 @@ func Finish(o Outcome, summarize func(Code) Problem) Answer { } // mustRegister panics on a code missing from the registry, because core uses -// only the codes that contract.md lists. -func mustRegister(o Outcome) { +// only the codes that contract.md lists, and an extension adds only those +// that its manifest declares. +func (r Registry) mustRegister(o Outcome) { check := func(p Problem, warning bool) { - _, isError := p.Code.Exit() - if (warning && !p.Code.IsWarning()) || (!warning && !isError) { + _, isError := r.exit(p.Code) + if (warning && !r.isWarning(p.Code)) || (!warning && !isError) { panic(fmt.Sprintf("contract: unregistered code %q", p.Code)) } } @@ -170,15 +191,16 @@ func mustRegister(o Outcome) { } } -// WriteJSON prints the answer as one line: on stdout for a success, and at -// the end of stderr for a failure, after any diagnostics. +// WriteJSON prints the answer as one line: on stdout for a success or a +// report, and at the end of stderr for an answer with errors, after any +// diagnostics. func (a Answer) WriteJSON(stdout, stderr io.Writer) error { line, err := json.Marshal(a) if err != nil { return err } w := stdout - if !a.OK() { + if len(a.errors) > 0 { w = stderr } _, err = w.Write(append(line, '\n')) diff --git a/sdk/contract/answercheck.go b/sdk/contract/answercheck.go index 8edcf859..f80819d9 100644 --- a/sdk/contract/answercheck.go +++ b/sdk/contract/answercheck.go @@ -136,13 +136,7 @@ func CheckAnswer(m Manifest, kind ResultKind, r Reply) (ExtensionAnswer, error) return fail("extension.answer_item_failed") } } - own := func(c Code) (ExitCode, bool) { - i := slices.IndexFunc(m.Errors, func(o OwnCode) bool { return o.Code == c }) - if i < 0 { - return c.Exit() - } - return m.Errors[i].Exit, true - } + own := m.Registry().exit for _, p := range a.allErrors() { if _, known := own(p.Code); !known && strings.HasPrefix(string(p.Code), m.codePrefix()) { return fail("extension.answer_undeclared", map[string]any{"Code": string(p.Code)}) diff --git a/sdk/contract/codes.go b/sdk/contract/codes.go index c3483ca1..90315a79 100644 --- a/sdk/contract/codes.go +++ b/sdk/contract/codes.go @@ -3,7 +3,10 @@ // declarations. docs/north-star/contract.md is the source of truth. package contract -import "slices" +import ( + "slices" + "strings" +) // ExitCode is the process exit status of an invocation. type ExitCode int @@ -61,10 +64,56 @@ func (e ExitCode) Known() bool { return e == ExitOK || slices.ContainsFunc(errorCodes, func(c codeExit) bool { return c.exit == e }) } -// Exit returns the exit code that an error code maps to, and false for a -// code that is not a registered error. -func (c Code) Exit() (ExitCode, bool) { - for _, e := range errorCodes { +// Exit returns the exit code that one of core's error codes maps to, and +// false for any other code. +func (c Code) Exit() (ExitCode, bool) { return Core.exit(c) } + +// IsWarning reports whether c is one of core's warning codes. +func (c Code) IsWarning() bool { return Core.isWarning(c) } + +// Registry holds the codes that an answer may use, as Error codes in +// contract.md defines: core's, and those of the extension that answers. +// Its error codes compete to lead in its order. +type Registry struct { + errors []codeExit + warnings []Code + // prefix admits every warning of an extension, such as HELLO_. + prefix string +} + +// Core is the registry of core's own commands. +var Core = Registry{errors: errorCodes, warnings: warningCodes} + +// Registry returns the registry of the extension that the manifest +// declares, ordered as Output in contract.md defines: each own code comes +// after core's codes of its exit, except for exit 1, where the own codes +// come before RUNTIME. Every warning that starts with the domain and an +// underscore belongs to the extension too. +func (m Manifest) Registry() Registry { + own := func(exit ExitCode) []codeExit { + var codes []codeExit + for _, e := range m.Errors { + if e.Exit == exit { + codes = append(codes, codeExit{e.Code, e.Exit}) + } + } + return codes + } + r := Registry{warnings: warningCodes, prefix: m.codePrefix()} + for i, e := range errorCodes { + if e.code == Runtime { + r.errors = append(r.errors, own(ExitRuntime)...) + } + r.errors = append(r.errors, e) + if last := i+1 == len(errorCodes) || errorCodes[i+1].exit != e.exit; last && e.exit != ExitRuntime { + r.errors = append(r.errors, own(e.exit)...) + } + } + return r +} + +func (r Registry) exit(c Code) (ExitCode, bool) { + for _, e := range r.errors { if e.code == c { return e.exit, true } @@ -72,22 +121,16 @@ func (c Code) Exit() (ExitCode, bool) { return 0, false } -// IsWarning reports whether c is a registered warning code. -func (c Code) IsWarning() bool { - for _, w := range warningCodes { - if w == c { - return true - } - } - return false +func (r Registry) isWarning(c Code) bool { + return slices.Contains(r.warnings, c) || r.prefix != "" && strings.HasPrefix(string(c), r.prefix) } // rank orders error codes for the lead; a lower rank is more cautious. -func (c Code) rank() int { - for i, e := range errorCodes { +func (r Registry) rank(c Code) int { + for i, e := range r.errors { if e.code == c { return i } } - return len(errorCodes) + return len(r.errors) } diff --git a/sdk/contract/compat_test.go b/sdk/contract/compat_test.go index 2a27d46a..7a604cea 100644 --- a/sdk/contract/compat_test.go +++ b/sdk/contract/compat_test.go @@ -15,7 +15,7 @@ func summary(code Code) Problem { return Problem{Code: code, Message: "summary", func render(t *testing.T, o Outcome) (Answer, string) { t.Helper() - a := Finish(o, summary) + a := Core.Finish(o, summary) line, err := json.Marshal(a) if err != nil { t.Fatal(err) @@ -124,7 +124,7 @@ func TestAnOlderConsumerIgnoresNewOptionalKeys(t *testing.T) { func TestDataCannotTakeAnEnvelopeKey(t *testing.T) { for _, key := range []string{"ok", "contract_version", "results", "errors", "warnings"} { - a := Finish(Outcome{Data: map[string]int{key: 1}}, summary) + a := Core.Finish(Outcome{Data: map[string]int{key: 1}}, summary) if _, err := json.Marshal(a); err == nil { t.Errorf("data with the key %q rendered", key) } @@ -143,7 +143,7 @@ func TestWriteJSONPicksTheStream(t *testing.T) { for _, c := range cases { var stdout, stderr bytes.Buffer stderr.WriteString("diagnostic line\n") - if err := Finish(c.in, summary).WriteJSON(&stdout, &stderr); err != nil { + if err := Core.Finish(c.in, summary).WriteJSON(&stdout, &stderr); err != nil { t.Fatal(err) } if stdout.String() != c.out || stderr.String() != "diagnostic line\n"+c.error { diff --git a/sdk/contract/verdict_test.go b/sdk/contract/verdict_test.go index bb219a89..63d38e5e 100644 --- a/sdk/contract/verdict_test.go +++ b/sdk/contract/verdict_test.go @@ -1,7 +1,9 @@ package contract import ( + "bytes" "slices" + "strings" "testing" ) @@ -35,12 +37,15 @@ func TestDecide(t *testing.T) { {"batch whose finished item wrote a file", Outcome{Results: []Item{item(), item(Temporary)}, Effect: NonRepeatableEffect}, Verdict{1, Runtime}}, {"batch with a runtime item", Outcome{Results: []Item{item(Temporary), item(Runtime)}}, Verdict{1, Runtime}}, {"batch with an item that needs a human", Outcome{Results: []Item{item(), item(TerminalRequired)}}, Verdict{1, Runtime}}, + {"a failed item makes the verdict the batch's, before a command error", Outcome{Results: []Item{item(), item(Runtime)}, Errors: []Problem{fail(ConfigInvalid)}}, Verdict{1, Runtime}}, + {"a signal's error from a nested command wins over item failures", Outcome{Results: []Item{item(), item(Runtime)}, Errors: []Problem{fail(Terminated)}}, Verdict{143, Terminated}}, + {"a temporary batch with a temporary command error", Outcome{Results: []Item{item(), item(Temporary)}, Errors: []Problem{fail(Temporary)}}, Verdict{75, Temporary}}, {"Ctrl-C wins over item failures", Outcome{Results: []Item{item(), item(Runtime)}, Signal: Interrupt}, Verdict{130, Interrupted}}, {"SIGTERM wins over command failures", Outcome{Errors: []Problem{fail(Runtime)}, Signal: Terminate}, Verdict{143, Terminated}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := Decide(c.in); got != c.want { + if got := Core.Decide(c.in); got != c.want { t.Errorf("Decide = %+v, want %+v", got, c.want) } reversed := c.in @@ -48,7 +53,7 @@ func TestDecide(t *testing.T) { slices.Reverse(reversed.Errors) reversed.Results = slices.Clone(c.in.Results) slices.Reverse(reversed.Results) - if got := Decide(reversed); got != c.want { + if got := Core.Decide(reversed); got != c.want { t.Errorf("Decide with failures in reverse order = %+v, want %+v", got, c.want) } }) @@ -69,7 +74,7 @@ func TestFinishPanicsOnAnUnregisteredCode(t *testing.T) { t.Error("Finish accepted a code that contract.md does not register") } }() - Finish(o, func(Code) Problem { return Problem{} }) + Core.Finish(o, func(Code) Problem { return Problem{} }) }) } } @@ -98,3 +103,64 @@ func TestDetectMode(t *testing.T) { } } } + +func TestAnExtensionsOwnCodesCompeteInTheOrderOfOutput(t *testing.T) { + m := Manifest{Domain: "hello", Errors: []OwnCode{{"HELLO_GRUMPY", ExitRuntime}, {"HELLO_LOCKED", ExitHuman}, {"HELLO_ODD", ExitUsage}}} + r := m.Registry() + cases := []struct { + name string + codes []Code + want Verdict + }{ + {"an own code of exit 1 leads RUNTIME", []Code{Runtime, "HELLO_GRUMPY"}, Verdict{1, "HELLO_GRUMPY"}}, + {"core's codes of exit 78 lead an own one", []Code{"HELLO_LOCKED", TerminalRequired}, Verdict{78, TerminalRequired}}, + {"core's codes of exit 2 lead an own one", []Code{"HELLO_ODD", UsageInvalid}, Verdict{2, UsageInvalid}}, + {"exit 1 leads exit 78", []Code{"HELLO_LOCKED", "HELLO_GRUMPY"}, Verdict{1, "HELLO_GRUMPY"}}, + } + for _, c := range cases { + var o Outcome + for _, code := range c.codes { + o.Errors = append(o.Errors, fail(code)) + } + if got := r.Decide(o); got != c.want { + t.Errorf("%s: Decide = %+v, want %+v", c.name, got, c.want) + } + } + a := r.Finish(Outcome{Warnings: []Problem{fail("HELLO_UNNAMED")}}, summary) + if !a.OK() { + t.Errorf("a warning of the extension changed the verdict to %+v", a.Verdict) + } +} + +func TestAReportThatNeedsAHumanAnswersOnStdout(t *testing.T) { + a := Core.Finish(Outcome{Data: map[string]any{"checks": []string{"fail"}}, HumanNeeded: true}, summary) + if a.Exit != ExitHuman || a.OK() { + t.Fatalf("verdict %+v, want exit 78 and ok false", a.Verdict) + } + var stdout, stderr strings.Builder + if err := a.WriteJSON(&stdout, &stderr); err != nil { + t.Fatal(err) + } + if want := "{\"ok\":false,\"contract_version\":1,\"checks\":[\"fail\"]}\n"; stdout.String() != want || stderr.Len() > 0 { + t.Errorf("stdout %q, stderr %q, want stdout %q", stdout.String(), stderr.String(), want) + } +} + +// TestCoreAcceptsTheSDKsAnswerOfAFailedBatch renders a batch whose failed +// item comes with an error of the command itself, and checks the answer as +// core does. +func TestCoreAcceptsTheSDKsAnswerOfAFailedBatch(t *testing.T) { + m := Manifest{Domain: "hello", Errors: []OwnCode{{"HELLO_GRUMPY", ExitRuntime}}} + for _, code := range []Code{"HELLO_GRUMPY", ConfigInvalid, Interrupted, Terminated} { + o := Outcome{Results: []Item{item(), item(Runtime)}, Errors: []Problem{fail(code)}} + a := m.Registry().Finish(o, summary) + var stdout, stderr bytes.Buffer + if err := a.WriteJSON(&stdout, &stderr); err != nil { + t.Fatal(err) + } + reply := Reply{Exit: int(a.Exit), Stdout: stdout.Bytes(), Last: stderr.Bytes()} + if _, err := CheckAnswer(m, ResultBatch, reply); err != nil { + t.Errorf("core rejects the answer %s: %v", stderr.Bytes(), err) + } + } +} diff --git a/sdk/extension/extension.go b/sdk/extension/extension.go new file mode 100644 index 00000000..7a940f05 --- /dev/null +++ b/sdk/extension/extension.go @@ -0,0 +1,276 @@ +// Package extension runs a Go extension from one value, as SDK in +// contract.md defines: started as ncly-, or compiled into ncly and +// started by it under that name. Either way it parses the call with its +// manifest, runs the handler of the command, and prints the answer by the +// kind of the command's result. +package extension + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/BurntSushi/toml" + "github.com/pascalandy/nicely/sdk/command" + "github.com/pascalandy/nicely/sdk/contract" + "github.com/pascalandy/nicely/sdk/i18n" + "github.com/pascalandy/nicely/sdk/runner" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Extension is a Go extension: its manifest, its catalogs, and one handler +// per command that runs. +type Extension struct { + // Manifest holds manifest.toml. + Manifest []byte + // Locales holds locales/.toml. + Locales fs.FS + // Handlers holds one handler per command that runs, keyed by path, + // such as "hello run". + Handlers map[string]Handler +} + +// Handler runs one validated call. ctx ends on the first Ctrl-C or SIGTERM, +// and the handler then returns what it has. +type Handler func(ctx context.Context, call Call) contract.Outcome + +// Call is one validated call of a command. +type Call struct { + // Path is the command path, such as "hello run". + Path string + // Args holds the positional arguments. + Args []string + // Flags holds each flag that the caller set, by long name. A switch + // holds "true" or "false". + Flags map[string]string + JSON bool + Mode contract.Mode + Printer *i18n.Printer + Stdin io.Reader + // Stdout takes human text only: the SDK prints the answer. Under --json, + // it discards what a command other than a text command writes. + Stdout io.Writer + Stderr io.Writer +} + +// Main runs the extension as this process with the arguments of the +// process, then exits with its exit code. +func Main(e Extension) { + signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE) + os.Exit(run(e, os.Args[1:], os.Stdout, os.Stderr)) +} + +// loaded is an extension whose manifest and catalogs were read. +type loaded struct { + Extension + manifest contract.Manifest + catalogs *i18n.Catalogs +} + +// load reads the manifest and the catalogs, and checks the handlers, as +// Main does when it starts. +func load(e Extension) (loaded, error) { + l := loaded{Extension: e} + var head struct { + Domain string `toml:"domain"` + } + if _, err := toml.Decode(string(e.Manifest), &head); err != nil { + return l, fmt.Errorf("manifest: %w", err) + } + var err error + if l.manifest, err = contract.ParseManifest(e.Manifest, head.Domain); err != nil { + return l, fmt.Errorf("manifest: %w", err) + } + locales, err := fs.Sub(e.Locales, "locales") + if err != nil { + return l, err + } + own, err := i18n.Load(locales) + if err != nil { + return l, fmt.Errorf("catalogs: %w", err) + } + if err := l.manifest.CheckCatalog(own.Has); err != nil { + return l, fmt.Errorf("catalogs: %w", err) + } + if l.catalogs, err = i18n.Load(i18n.Locales(), locales); err != nil { + return l, fmt.Errorf("catalogs: %w", err) + } + for _, c := range l.manifest.Commands { + path := strings.Join(c.Path, " ") + if l.manifest.Runs(c) && e.Handlers[path] == nil { + return l, fmt.Errorf("the command %q runs but has no handler", path) + } + } + for path := range e.Handlers { + if c, found := l.manifest.Command(path); !found || !l.manifest.Runs(c) { + return l, fmt.Errorf("the handler %q has no command that runs", path) + } + } + return l, nil +} + +// Check reports what Main would refuse, and a catalog that breaks the +// catalog rules of SDK in contract.md. Every Go extension has a test that +// calls it. +func Check(e Extension) error { + l, err := load(e) + if err != nil { + return err + } + locales, err := fs.Sub(e.Locales, "locales") + if err != nil { + return err + } + return i18n.CheckCatalogs(locales, l.manifest.Domain) +} + +// run runs the extension with args, without the program name, and returns +// its exit code. +func run(e Extension, args []string, stdout, stderr *os.File) int { + l, err := load(e) + if err != nil { + return broken(err, args, stdout, stderr) + } + domain := l.manifest.Domain + // The tree has the root ncly above the domain, so that hints and help + // read as they do through core. + args = append([]string{domain}, args...) + en := l.catalogs.Printer("en") + cmd, _, _ := l.tree(en, nil).Find(args) + line := command.Scan(cmd, args) + g := command.Resolve(line) + p := l.catalogs.Printer(g.Lang) + var selected *selection + root := l.tree(p, func(s selection) error { + selected = &s + return nil + }) + out := command.NewOutput(stdout) + root.SetArgs(args) + root.SetOut(out) + root.SetErr(stderr) + root.SetHelpFunc(command.HelpFunc(p, g.NoColor)) + codes := l.manifest.Registry() + switch { + case line.Version() && !line.Help(): + _, err = fmt.Fprintf(out, "ncly-%s %s\n", domain, command.ModuleVersion()) + case line.Help(): + target, _, _ := root.Find(args) + err = target.Help() + default: + err = root.Execute() + } + if selected != nil && err == nil { + return l.handle(*selected, p, g, codes, out, stdout, stderr) + } + return command.Finish(p, g, codes, out, err, "ncly "+domain+" --help", stdout, stderr) +} + +// selection is the command and the call that the parser accepted. +type selection struct { + command contract.Command + call Call +} + +// tree builds the command tree of the manifest under a root named ncly. +// Each command that runs reports its call to selected, when it is set. +func (l loaded) tree(p *i18n.Printer, selected func(selection) error) *cobra.Command { + root := &cobra.Command{Use: "ncly", SilenceErrors: true, SilenceUsage: true} + root.CompletionOptions.DisableDefaultCmd = true + command.AddGlobalFlags(root, p) + command.AddManifest(root, p, p, l.manifest, func(d contract.Command) func(*cobra.Command, []string) error { + if selected == nil { + return nil + } + return func(c *cobra.Command, args []string) error { + flags := map[string]string{} + for _, f := range d.Flags { + if flag := c.Flags().Lookup(f.Name); flag.Changed { + flags[f.Name] = flag.Value.String() + } + } + path := strings.Join(d.Path, " ") + return selected(selection{command: d, call: Call{Path: path, Args: args, Flags: flags}}) + } + }) + return root +} + +// handle runs the handler of a validated call and prints its result by the +// kind of the command's result. The first Ctrl-C or SIGTERM ends the +// handler's context and decides the verdict. A handler that has not +// returned once cleanup and the drain limit have passed gets no more time. +func (l loaded) handle(s selection, p *i18n.Printer, g command.Globals, codes contract.Registry, out *command.Output, stdout, stderr *os.File) int { + kind := s.command.Result.Kind + call := s.call + call.JSON, call.Printer = g.JSON, p + call.Mode = contract.DetectMode(term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(stdout.Fd())), g.NoInput, g.JSON, os.LookupEnv) + call.Stdin, call.Stdout, call.Stderr = os.Stdin, io.Writer(out), io.Writer(stderr) + if g.JSON && kind != contract.ResultText { + call.Stdout = io.Discard + } + // Run directly, the extension resolves the global flags itself, so that + // its handler and the handler's programs see the variables that ncly + // would set. + for name, value := range g.Vars() { + if value == "" { + _ = os.Unsetenv(name) + } else { + _ = os.Setenv(name, value) + } + } + ctx := runner.OnSignal() + done := make(chan contract.Outcome, 1) + go func() { done <- l.Handlers[call.Path](ctx, call) }() + var o contract.Outcome + select { + case o = <-done: + case <-ctx.Done(): + // The runner ends the handler's programs with SIGKILL when cleanup + // ends, then reads their streams for the drain limit, so the handler + // gets that long to return what it has. + select { + case o = <-done: + case <-time.After(runner.Cleanup + runner.DrainLimit): + o = contract.Outcome{} + } + } + if ctx.Err() != nil { + o.Signal = contract.StopSignal(runner.SignalOf(ctx)) + } + if out.Err() != nil { + return command.Report(p, g, codes, command.WriteFailure(p), stdout, stderr) + } + if kind == contract.ResultText && codes.Decide(o).Exit == contract.ExitOK { + // A text result prints its text, never an answer, so its warnings + // print as text too. + g.JSON = false + } + return command.Report(p, g, codes, o, stdout, stderr) +} + +// broken answers RUNTIME for an extension that does not match its own +// manifest, with the SDK's text alone. With --verbose, the reason comes +// first on stderr. +func broken(reason error, args []string, stdout, stderr *os.File) int { + sdk, err := i18n.Load(i18n.Locales()) + if err != nil { + return int(contract.ExitRuntime) + } + root := &cobra.Command{Use: "ncly"} + command.AddGlobalFlags(root, sdk.Printer("en")) + g := command.Resolve(command.Scan(root, args)) + p := sdk.Printer(g.Lang) + if g.Verbose { + _, _ = fmt.Fprintln(stderr, reason) + } + problem := contract.Problem{Code: contract.Runtime, Message: p.T("outcome.extension_broken"), Hint: p.T("outcome.extension_broken_hint")} + return command.Report(p, g, contract.Core, contract.Outcome{Errors: []contract.Problem{problem}}, stdout, stderr) +} diff --git a/sdk/extension/extension_test.go b/sdk/extension/extension_test.go new file mode 100644 index 00000000..a1d6f6d1 --- /dev/null +++ b/sdk/extension/extension_test.go @@ -0,0 +1,99 @@ +package extension + +import ( + "context" + "strings" + "testing" + "testing/fstest" + + "github.com/pascalandy/nicely/sdk/contract" +) + +const validManifest = `format_version = 1 +contract_version = 1 +domain = "demo" + +[[commands]] +path = "demo" +summary = "demo.summary" + +[[commands]] +path = "demo run" +summary = "demo.run.summary" + +[[errors]] +code = "DEMO_FAILED" +exit = 1 +` + +const validCatalog = `["demo.summary"] +description = "Summary of the demo extension." +other = "Demo" + +["demo.run.summary"] +description = "Summary of demo run." +other = "Run the demo" +` + +func noop(context.Context, Call) contract.Outcome { return contract.Outcome{} } + +// demo returns a valid extension, which each case breaks in one way. +func demo() Extension { + return Extension{ + Manifest: []byte(validManifest), + Locales: fstest.MapFS{"locales/en.toml": {Data: []byte(validCatalog)}}, + Handlers: map[string]Handler{"demo run": noop}, + } +} + +func TestCheckAcceptsAValidExtension(t *testing.T) { + if err := Check(demo()); err != nil { + t.Fatal(err) + } +} + +func TestCheckRejectsWhatMainWouldRefuse(t *testing.T) { + cases := []struct { + name string + spoil func(*Extension) + want string + }{ + {"an own code without the domain prefix", func(e *Extension) { + e.Manifest = []byte(strings.Replace(validManifest, "DEMO_FAILED", "FAILED", 1)) + }, "extension.code_prefix"}, + {"a command that runs without a handler", func(e *Extension) { + e.Handlers = map[string]Handler{} + }, `"demo run" runs but has no handler`}, + {"a command that runs with a nil handler", func(e *Extension) { + e.Handlers["demo run"] = nil + }, `"demo run" runs but has no handler`}, + {"a handler for no command", func(e *Extension) { + e.Handlers["demo stop"] = noop + }, `"demo stop" has no command that runs`}, + {"a handler for a group that only prints its help", func(e *Extension) { + e.Handlers["demo"] = noop + }, `"demo" has no command that runs`}, + {"a catalog ID without the domain prefix", func(e *Extension) { + e.Locales = fstest.MapFS{"locales/en.toml": {Data: []byte(validCatalog + "\n[\"other.text\"]\ndescription = \"d\"\nother = \"o\"\n")}} + }, `other.text does not start with "demo."`}, + {"an entry without a description", func(e *Extension) { + e.Locales = fstest.MapFS{"locales/en.toml": {Data: []byte(strings.Replace(validCatalog, `description = "Summary of demo run."`, "", 1))}} + }, "demo.run.summary has no description"}, + {"a translation that lacks an ID of en", func(e *Extension) { + e.Locales = fstest.MapFS{ + "locales/en.toml": {Data: []byte(validCatalog)}, + "locales/fr-CA.toml": {Data: []byte("[\"demo.summary\"]\ndescription = \"d\"\nother = \"Démo\"\n")}, + } + }, "fr-CA.toml lacks demo.run.summary"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := demo() + c.spoil(&e) + err := Check(e) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Check = %v, want an error about %s", err, c.want) + } + }) + } +} diff --git a/sdk/i18n/locales/en.toml b/sdk/i18n/locales/en.toml index bc8e56db..e1826f8e 100644 --- a/sdk/i18n/locales/en.toml +++ b/sdk/i18n/locales/en.toml @@ -140,3 +140,11 @@ other = "warning[{{.Code}}]:" ["error.hint"] description = "Error block: label before the command or action that fixes an error or a warning, as in `hint: ncly --help`." other = "hint:" + +["outcome.extension_broken"] +description = "Error when a Go extension does not match its own manifest, such as a command without a handler. Only its developer can fix it." +other = "This extension does not match its own manifest." + +["outcome.extension_broken_hint"] +description = "Hint under outcome.extension_broken: what the user does next. --verbose is a flag and stays as is." +other = "Report it to the extension's author, with the details that --verbose shows." diff --git a/sdk/runner/runner.go b/sdk/runner/runner.go index e1d589e8..5f0487e3 100644 --- a/sdk/runner/runner.go +++ b/sdk/runner/runner.go @@ -53,17 +53,17 @@ type Result struct { // shortens it. var Cleanup = 15 * time.Second -// drainLimit bounds how long the runner reads a program's streams after the +// DrainLimit bounds how long the runner reads a program's streams after the // program exits, because a descendant may hold them open. It runs on its // own clock, apart from Cleanup. -const drainLimit = 2 * time.Second +const DrainLimit = 2 * time.Second // Run starts the program and waits for it to exit. When ctx ends first, Run // sends the signal that ended it, or SIGTERM for any other end, to every // descendant of ncly that it can reach, the program included, and sends // SIGKILL to what still runs once Cleanup has passed. A later end of ctx // changes nothing. Run reads a stream that is not a file for at most -// drainLimit after the program exits. +// DrainLimit after the program exits. func Run(ctx context.Context, p Program) (Result, error) { cmd := exec.Command(p.Path, p.Args...) cmd.Env = p.Env @@ -133,7 +133,7 @@ func Run(ctx context.Context, p Program) (Result, error) { // The drain limit counts from the exit, never from the end of cleanup, // so a descendant that outlives the program cannot write into its // answer once the limit has passed. - drain(streams, exitedAt.Add(drainLimit)) + drain(streams, exitedAt.Add(DrainLimit)) stopping.Wait() result.Exit = exitCode(cmd.ProcessState) return result, nil @@ -190,9 +190,13 @@ type stream struct { // attach returns what the program writes to: a file as it is, so that the // program sees a terminal when it is one, and otherwise the write end of a -// pipe whose copy to w the runner controls. Two streams to the same writer -// share one pipe, as exec.Cmd does, so their writes never overlap. +// pipe whose copy to w the runner controls. A writer that wraps a file, such +// as the stdout of an SDK handler, passes its file. Two streams to the same +// writer share one pipe, as exec.Cmd does, so their writes never overlap. func attach(w io.Writer, streams *[]*stream) (io.Writer, error) { + if wrapper, wraps := w.(interface{ File() *os.File }); wraps { + w = wrapper.File() + } if _, isFile := w.(*os.File); isFile || w == nil { return w, nil } diff --git a/sdk/runner/runner_test.go b/sdk/runner/runner_test.go index 21b817ce..383d7e4f 100644 --- a/sdk/runner/runner_test.go +++ b/sdk/runner/runner_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/pascalandy/nicely/sdk/command" "github.com/pascalandy/nicely/sdk/runner" ) @@ -156,3 +157,22 @@ func TestRunKillsWhatHoldsAStreamWhenCleanupEnds(t *testing.T) { t.Fatalf("Run took %v, so SIGKILL waited for the drain limit", took) } } + +// TestAProgramWritesToTheFileThatAnOutputWraps proves that a program started +// with the stdout of an SDK handler writes to the file itself, never to a +// pipe, so it sees a terminal when the file is one. +func TestAProgramWritesToTheFileThatAnOutputWraps(t *testing.T) { + f, err := os.Create(filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + result, err := runner.Run(context.Background(), runner.Program{ + Path: "/bin/sh", + Args: []string{"-c", "test ! -p /dev/stdout"}, + Stdout: command.NewOutput(f), + }) + if err != nil || result.Exit != 0 { + t.Errorf("the program wrote to a pipe: exit %d, error %v", result.Exit, err) + } +} diff --git a/testdata/extensions/hello/cmd/ncly-hello/main.go b/testdata/extensions/hello/cmd/ncly-hello/main.go new file mode 100644 index 00000000..396d7cbe --- /dev/null +++ b/testdata/extensions/hello/cmd/ncly-hello/main.go @@ -0,0 +1,9 @@ +// Command ncly-hello runs the hello fixture as an external extension. +package main + +import ( + "github.com/pascalandy/nicely/sdk/extension" + hello "github.com/pascalandy/nicely/testdata/extensions/hello" +) + +func main() { extension.Main(hello.Extension) } diff --git a/testdata/extensions/hello/hello.go b/testdata/extensions/hello/hello.go new file mode 100644 index 00000000..341e4534 --- /dev/null +++ b/testdata/extensions/hello/hello.go @@ -0,0 +1,125 @@ +// Package hello is the Go fixture extension that proves the SDK. It mirrors +// the shell fixtures of M01: an own code, an own warning, a report, a text +// result, and a command that runs a program until a signal arrives. +package hello + +import ( + "context" + "embed" + "fmt" + "os" + + "github.com/pascalandy/nicely/sdk/contract" + "github.com/pascalandy/nicely/sdk/extension" + "github.com/pascalandy/nicely/sdk/runner" +) + +//go:embed manifest.toml +var manifest []byte + +//go:embed locales/*.toml +var locales embed.FS + +// Extension is the hello fixture, for both transports. +var Extension = extension.Extension{ + Manifest: manifest, + Locales: locales, + Handlers: map[string]extension.Handler{ + "hello run": run, + "hello check": check, + "hello say": say, + "hello wait": wait, + }, +} + +// greeting greets the name of the call with its --greeting, and reports +// whether the call named someone. +func greeting(call extension.Call) (string, bool) { + name, named := call.Printer.T("hello.world"), len(call.Args) > 0 + if named { + name = call.Args[0] + } + word, given := call.Flags["greeting"] + if !given { + word = call.Printer.T("hello.default_greeting") + } + return call.Printer.T("hello.greeting", map[string]any{"Greeting": word, "Name": name}), named +} + +func run(_ context.Context, call extension.Call) contract.Outcome { + p := call.Printer + if word, given := call.Flags["greeting"]; given && word == "" { + return contract.Outcome{Errors: []contract.Problem{{Code: contract.UsageInvalid, Message: p.T("hello.empty_greeting"), Hint: "ncly hello run --greeting Hi Ada"}}} + } + if call.Flags["grumpy"] == "true" { + return contract.Outcome{Errors: []contract.Problem{{Code: "HELLO_GRUMPY", Message: p.T("hello.grumpy"), Hint: "ncly hello run Ada"}}} + } + text, named := greeting(call) + if os.Getenv("NCLY_VERBOSE") == "1" { + _, _ = fmt.Fprintln(call.Stderr, p.T("hello.verbose", map[string]any{"Greeting": text})) + } + _, _ = fmt.Fprintln(call.Stdout, text) + o := contract.Outcome{Data: map[string]string{"greeting": text}} + if !named { + o.Warnings = []contract.Problem{{Code: "HELLO_UNNAMED", Message: p.T("hello.unnamed"), Hint: "ncly hello run Ada"}} + } + return o +} + +func check(_ context.Context, call extension.Call) contract.Outcome { + p := call.Printer + if call.Flags["fail"] == "true" { + return contract.Outcome{Errors: []contract.Problem{{Code: contract.ConfigInvalid, Message: p.T("hello.config"), Hint: "ncly hello check"}}} + } + _, _ = fmt.Fprintln(call.Stdout, p.T("hello.mood")) + return contract.Outcome{ + Data: map[string]any{"checks": []map[string]string{{"id": "hello.mood", "status": "fail"}}}, + HumanNeeded: true, + } +} + +func say(_ context.Context, call extension.Call) contract.Outcome { + text, named := greeting(call) + _, _ = fmt.Fprintln(call.Stdout, text) + if !named { + return contract.Outcome{Warnings: []contract.Problem{{Code: "HELLO_UNNAMED", Message: call.Printer.T("hello.unnamed"), Hint: "ncly hello say Ada"}}} + } + return contract.Outcome{} +} + +// program writes finished.txt, starts a grandchild in its own process +// group, and runs until a signal ends it. Under STUBBORN_CHILD, it ignores +// SIGTERM, so only SIGKILL ends it. +const program = ` +echo done > finished.txt +set -m +sleep 60 >/dev/null 2>&1 & +echo $! > grandchild.pid +set +m +if [ -n "$STUBBORN_CHILD" ]; then trap '' TERM; fi +echo $$ > program.tmp +mv program.tmp program.pid +while :; do sleep 0.1; done +` + +// wait runs program in the current folder until its context ends, then +// answers with that folder. Under --stubborn, it never returns. +func wait(ctx context.Context, call extension.Call) contract.Outcome { + stubbornChild := "" + if call.Flags["stubborn-child"] == "true" { + stubbornChild = "1" + } + _, err := runner.Run(ctx, runner.Program{ + Path: "/bin/sh", + Args: []string{"-c", program}, + Env: runner.Environ(os.Environ(), map[string]string{"STUBBORN_CHILD": stubbornChild}), + }) + if err != nil { + return contract.Outcome{Errors: []contract.Problem{{Code: contract.Runtime, Message: call.Printer.T("hello.program"), Hint: "ncly hello wait"}}} + } + if call.Flags["stubborn"] == "true" { + select {} + } + dir, _ := os.Getwd() + return contract.Outcome{Data: map[string]string{"output_dir": dir}} +} diff --git a/testdata/extensions/hello/hello_test.go b/testdata/extensions/hello/hello_test.go new file mode 100644 index 00000000..71a59df2 --- /dev/null +++ b/testdata/extensions/hello/hello_test.go @@ -0,0 +1,13 @@ +package hello + +import ( + "testing" + + "github.com/pascalandy/nicely/sdk/extension" +) + +func TestTheExtensionMatchesItsManifest(t *testing.T) { + if err := extension.Check(Extension); err != nil { + t.Fatal(err) + } +} diff --git a/testdata/extensions/hello/locales/en.toml b/testdata/extensions/hello/locales/en.toml new file mode 100644 index 00000000..d9573e40 --- /dev/null +++ b/testdata/extensions/hello/locales/en.toml @@ -0,0 +1,89 @@ +# English catalog of the hello fixture. Each entry needs a description of where the text appears. + +["hello.summary"] +description = "Help page: summary of the hello fixture, which greets people." +other = "Greet people" + +["hello.run.summary"] +description = "Help page: summary of hello run." +other = "Greet someone by name" + +["hello.run.name"] +description = "Help page: the name argument of hello run." +other = "Name of the person to greet" + +["hello.run.greeting"] +description = "Help page: the --greeting flag of hello run." +other = "Word to greet with" + +["hello.run.grumpy"] +description = "Help page: the --grumpy flag of hello run, which makes it refuse." +other = "Refuse to greet" + +["hello.check.summary"] +description = "Help page: summary of hello check, a report." +other = "Check that greetings work" + +["hello.check.fail"] +description = "Help page: the --fail flag of hello check, which makes the report itself fail." +other = "Fail before checking anything" + +["hello.say.summary"] +description = "Help page: summary of hello say, which prints plain text." +other = "Say hello as plain text" + +["hello.say.name"] +description = "Help page: the name argument of hello say." +other = "Name of the person to greet" + +["hello.wait.summary"] +description = "Help page: summary of hello wait, which runs a program until a signal arrives." +other = "Wait for a signal" + +["hello.wait.stubborn"] +description = "Help page: the --stubborn flag of hello wait, which makes it ignore the signal." +other = "Keep waiting after a signal" + +["hello.wait.stubborn_child"] +description = "Help page: the --stubborn-child flag of hello wait, whose program then ignores SIGTERM." +other = "Start a program that ignores SIGTERM" + +["hello.greeting"] +description = "Text of hello run and hello say. {{.Greeting}} is a word such as Hello, and {{.Name}} a name." +other = "{{.Greeting}}, {{.Name}}!" + +["hello.default_greeting"] +description = "The word that hello run greets with when --greeting is absent." +other = "Hello" + +["hello.world"] +description = "The name that hello run greets when no name is given." +other = "world" + +["hello.verbose"] +description = "Diagnostic line of hello run under --verbose. {{.Greeting}} is the greeting that it prints, such as Hello, Ada!" +other = "Greeting: {{.Greeting}}" + +["hello.unnamed"] +description = "Warning when hello run or hello say gets no name and greets the world instead." +other = "No name was given, so hello greets the world." + +["hello.empty_greeting"] +description = "Error when the --greeting flag of hello run is empty." +other = "The greeting cannot be empty." + +["hello.grumpy"] +description = "Error when hello run refuses to greet, under --grumpy." +other = "Not today." + +["hello.mood"] +description = "Text of hello check: its only check failed." +other = "The mood check failed." + +["hello.config"] +description = "Error when hello check fails before checking anything, under --fail." +other = "The configuration of hello cannot be read." + +["hello.program"] +description = "Error when hello wait could not start its program." +other = "The program of hello wait could not start." diff --git a/testdata/extensions/hello/manifest.toml b/testdata/extensions/hello/manifest.toml new file mode 100644 index 00000000..a67110d9 --- /dev/null +++ b/testdata/extensions/hello/manifest.toml @@ -0,0 +1,64 @@ +format_version = 1 +contract_version = 1 +domain = "hello" + +[[commands]] +path = "hello" +summary = "hello.summary" +examples = ["ncly hello run Ada", "ncly hello check --json"] + +[[commands]] +path = "hello run" +summary = "hello.run.summary" +examples = ["ncly hello run Ada", "ncly hello run --greeting Hi Ada --json"] + +[[commands.args]] +name = "name" +summary = "hello.run.name" + +[[commands.flags]] +name = "greeting" +shorthand = "g" +value = "text" +summary = "hello.run.greeting" + +[[commands.flags]] +name = "grumpy" +summary = "hello.run.grumpy" + +[[commands]] +path = "hello check" +summary = "hello.check.summary" +examples = ["ncly hello check", "ncly hello check --json"] +result = { kind = "report" } + +[[commands.flags]] +name = "fail" +summary = "hello.check.fail" + +[[commands]] +path = "hello say" +summary = "hello.say.summary" +examples = ["ncly hello say Ada", "ncly hello say Ada --json"] +result = { kind = "text" } + +[[commands.args]] +name = "name" +summary = "hello.say.name" + +[[commands]] +path = "hello wait" +summary = "hello.wait.summary" +examples = ["ncly hello wait", "ncly hello wait --json"] + +[[commands.flags]] +name = "stubborn" +summary = "hello.wait.stubborn" + +[[commands.flags]] +name = "stubborn-child" +summary = "hello.wait.stubborn_child" + +[[errors]] +code = "HELLO_GRUMPY" +exit = 1 diff --git a/testdata/extensions/hello/testdata/script/sdk_extension.txtar b/testdata/extensions/hello/testdata/script/sdk_extension.txtar new file mode 100644 index 00000000..130d71da --- /dev/null +++ b/testdata/extensions/hello/testdata/script/sdk_extension.txtar @@ -0,0 +1,137 @@ +# The Go fixture, built only on sdk/, answers through ncly as a core command does: +# the same envelope, exit codes, and error block. The setup installs it in the data +# folder $DATA as the script ncly-hello. + +# A success prints its text in human mode and its answer under --json +exec ncly hello run Ada +stdout '^Hello, Ada!$' +! stderr . +exec ncly hello run Ada --json +cmp stdout run.json +! stderr . + +# A success with the fixture's own warning keeps exit 0 +exec ncly hello run --json +answer stdout ok +stdout '"code":"HELLO_UNNAMED"' +exec ncly hello run +stdout '^Hello, world!$' +stderr '^warning\[HELLO_UNNAMED\]: No name was given, so hello greets the world\.$' + +# A usage error that the handler finds +exits 2 ncly hello run --greeting= Ada --json +! stdout . +answer stderr USAGE_INVALID +exits 2 ncly hello run --greeting= Ada +stderr '^error\[USAGE_INVALID\]: The greeting cannot be empty\.$' + +# The fixture's own code, with the exit code of its manifest +exits 1 ncly hello run --grumpy --json +! stdout . +answer stderr HELLO_GRUMPY +exits 1 ncly hello run --grumpy +! stdout . +cmp stderr grumpy.txt + +# A failed report answers on stdout with exit 78, and a report that fails itself on stderr +exits 78 ncly hello check --json +cmp stdout check.json +! stderr . +exits 78 ncly hello check --fail --json +! stdout . +answer stderr CONFIG_INVALID +exits 78 ncly hello check +stdout '^The mood check failed\.$' +! stderr . + +# A text result ignores --json, and keeps its warnings as text on stderr +exec ncly hello say Ada --json +stdout '\AHello, Ada!\n\z' +! stderr . +exec ncly hello say +stdout '^Hello, world!$' +stderr '^warning\[HELLO_UNNAMED\]: No name was given, so hello greets the world\.$' + +# The handler sees the resolved global flags as the variables that ncly sets, run +# through ncly or directly +exec ncly -v hello run Ada +stderr '^Greeting: Hello, Ada!$' +exec $DATA/ncly-hello run Ada --verbose +stderr '^Greeting: Hello, Ada!$' + +# Under the pseudo-locale, every word of the SDK's text and of the fixture's comes from a catalog +exec ncly --lang en-XA hello run Ada +pseudo stdout Ada +exits 1 ncly --lang en-XA hello run --grumpy +pseudo stderr hello run Ada +exec ncly --lang en-XA hello run +pseudo stderr hello run Ada +exec ncly --lang en-XA hello --help +pseudo stdout hello run check say wait + +# Run directly from the data folder, the fixture gives the same answer as through ncly +exec $DATA/ncly-hello run --json -- Ada +cmp stdout run.json +exits 1 $DATA/ncly-hello run --grumpy +cmp stderr grumpy.txt + +# Help wins over --version, as in core +exec $DATA/ncly-hello run --help --version +stdout '^USAGE$' +! stdout 'ncly-hello v' + +# An answer that stdout refuses becomes RUNTIME on stderr, with the keys that still apply +[linux] exits 1 sh full.sh +[linux] answer stderr RUNTIME +[linux] stderr '"greeting":"Hello, Ada!"' + +# Run directly, SIGTERM reaches the extension alone. Its SDK ends the handler's context and +# stops the program and its grandchild, the handler answers with its key, and the finished +# file stays +exec exitcode exit.txt $DATA/ncly-hello wait --json & +waitfile program.pid +term +wait +grep '^143$' exit.txt +! stdout . +answer stderr TERMINATED +stderr '"output_dir":"' +exists finished.txt +gone program.pid +gone grandchild.pid + +# A program that ignores SIGTERM ends with the SIGKILL of cleanup, and the answer keeps its key +rm program.pid grandchild.pid +exec exitcode exit.txt $DATA/ncly-hello wait --stubborn-child --json & +waitfile program.pid +term +wait +grep '^143$' exit.txt +answer stderr TERMINATED +stderr '"output_dir":"' +gone program.pid +gone grandchild.pid + +# A handler that ignores its context gets no time after cleanup: the SDK answers alone, +# and a second SIGTERM during cleanup changes nothing +rm program.pid grandchild.pid +exec exitcode exit.txt $DATA/ncly-hello wait --stubborn --json & +waitfile program.pid +term +gone program.pid +term +wait +grep '^143$' exit.txt +answer stderr TERMINATED +! stderr 'output_dir' + +-- full.sh -- +"$DATA/ncly-hello" run Ada --json >/dev/full +-- run.json -- +{"ok":true,"contract_version":1,"greeting":"Hello, Ada!"} +-- check.json -- +{"ok":false,"contract_version":1,"checks":[{"id":"hello.mood","status":"fail"}]} +-- grumpy.txt -- +error[HELLO_GRUMPY]: Not today. + + hint: ncly hello run Ada