From fb328867753f204c06babd1cbe3d0d014c1db151 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 12:21:24 +0200 Subject: [PATCH] feat(bonfire): Artisan help, examples, JSON listing and error hints - help and --help print Description, Usage, Arguments, Options with defaults, Global options, Help and Examples - Command.Examples and Command.Hidden (appended, additive); list --all shows hidden commands, list --format=json prints a bonfire.Listing - CheckDocs reports commands lacking description, long help, examples or argument and option descriptions - Error block with the ERROR badge, WithHint explanation and Try lines ({bin} replaced), cobra suggestions as Try, -v error chain, messages scrubbed by sunscreen; Reported errors print nothing and exit 1 --- docs/console/introduction.md | 6 + docs/console/writing-commands.md | 130 +++++++++++++++- modules/bonfire/README.md | 28 +++- modules/bonfire/command.go | 20 ++- modules/bonfire/docs.go | 55 +++++++ modules/bonfire/errors.go | 188 ++++++++++++++++++++++- modules/bonfire/example_test.go | 109 +++++++++++++ modules/bonfire/execute.go | 8 +- modules/bonfire/help.go | 125 +++++++++++++++ modules/bonfire/help_smoke_test.go | 235 +++++++++++++++++++++++++++++ modules/bonfire/list.go | 201 +++++++++++++++++++++--- modules/bonfire/root.go | 77 +++++++++- 12 files changed, 1130 insertions(+), 52 deletions(-) create mode 100644 modules/bonfire/docs.go create mode 100644 modules/bonfire/help.go create mode 100644 modules/bonfire/help_smoke_test.go diff --git a/docs/console/introduction.md b/docs/console/introduction.md index 150c9760..63ba545a 100644 --- a/docs/console/introduction.md +++ b/docs/console/introduction.md @@ -62,10 +62,16 @@ Both programs list their commands with no arguments, with `--help` and with `lis ```sh summer summer make:model --help +summer help migrate ./bin/acme --help ./bin/acme migrate:rollback --help +./bin/acme help migrate:rollback +./bin/acme list --all +./bin/acme list --format=json ``` +A command's help shows its description, usage, arguments, options with their defaults, the global options, the long help and examples. `list --all` includes hidden commands, and `list --format=json` prints every command with its arguments, options and examples for scripts and agents. + A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary. ### Global options diff --git a/docs/console/writing-commands.md b/docs/console/writing-commands.md index 28cfb29e..4f9578ae 100644 --- a/docs/console/writing-commands.md +++ b/docs/console/writing-commands.md @@ -16,7 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag - Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `` and optional ones as `[name]`. - Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times. - `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`. -- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked. +- `bonfire.Command.Long` is optional extended help, shown under "Help:" by `help ` and ` --help`. `bonfire.Command.Examples` and `bonfire.Command.Hidden` are described below. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked. Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given: @@ -60,7 +60,133 @@ A scaffolded plugin's `Commands` method returns the generated list of everything - widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal; - prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs. -Return an error from `Run` to fail the command. The binary prints it and exits with status 1. +Return an error from `Run` to fail the command. The binary prints it as an error block on stderr and exits with status 1; see [Errors and hints](#errors-and-hints). + +## Examples and hidden commands + +`bonfire.Command.Examples` lists usage examples. Each `bonfire.Example` holds the command line without the binary and a short description; help prints the description as a `#` comment and the line after `bin/` (or `summer` for the tool's own commands), so one example serves both. `bonfire.Command.Hidden` keeps a command that still runs out of the list; `list --all` and `list --format=json` show it: + +```go src=modules/bonfire/example_test.go#ExampleExample +export := bonfire.Command{ + Name: "blog:export", + Description: "Export published posts as JSON", + Long: "Writes every published post, newest first, to a JSON file.", + Flags: []bonfire.Flag{ + {Name: "path", Description: "Output file", Default: "posts.json"}, + }, + Examples: []bonfire.Example{ + {Command: "blog:export --path=backup.json", Description: "Export to another file"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + return nil + }, +} +// Hidden commands run, but list shows them only with --all. +cleanup := bonfire.Command{ + Name: "blog:cleanup", + Description: "Remove orphaned uploads", + Hidden: true, +} + +root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout) +if err != nil { + fmt.Println(err) + return +} +bonfire.Execute(context.Background(), root, []string{"help", "blog:export"}) +// Output: +// Description: +// Export published posts as JSON +// +// Usage: +// blog:export [options] +// +// Options: +// --path=PATH Output file [default: "posts.json"] +// +// Global options: +// -h, --help Display help for the given command +// -q, --quiet Do not output any message +// -v|vv|vvv Increase verbosity +// --ansi|no-ansi Force (or disable) ANSI output +// -n, --no-interaction Do not ask any interactive question +// --env The environment the command runs under +// +// Help: +// Writes every published post, newest first, to a JSON file. +// +// Examples: +// # Export to another file +// bin/acme blog:export --path=backup.json +``` + +`list --format=json` prints every command with its arguments, options (with defaults) and examples as a `bonfire.Listing`, for documentation checks, scripts and agents. `bonfire.Describe` builds the same listing from a command slice. + +## Errors and hints + +When `Run` returns an error, the binary prints a block on stderr: the ERROR badge and the message, then "Run with -v for details.". With `-v` the block lists the wrapped errors under "Caused by:" instead. Messages pass through `sunscreen.Scrub`, so a token inside an error text is not printed. + +Wrap an error with `bonfire.WithHint` to explain it and to say what to run next. `{bin}` in the Try text becomes `bin/` or `summer`, so the hint names the right program: + +```go src=modules/bonfire/example_test.go#ExampleWithHint +publish := bonfire.Command{ + Name: "blog:publish", + Description: "Publish a draft post", + Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + slug, _ := in.Argument("slug") + return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug), + "Only drafts can be published; published posts keep their slug.", + "{bin} blog:drafts") + }, +} + +root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout) +if err != nil { + fmt.Println(err) + return +} +code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"}) +fmt.Println("exit code:", code) +// Output: +// ERROR blog: no draft named "hello-world" +// +// Only drafts can be published; published posts keep their slug. +// +// Try: +// bin/acme blog:drafts +// +// Run with -v for details. +// +// exit code: 1 +``` + +A command that has already printed its problems, such as a checker that lists every finding, returns `bonfire.Reported(err)`: the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests `help `; wrap your own usage checks with `bonfire.Usage` to get the same exit code. + +## Documentation coverage + +`bonfire.CheckDocs` reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems: + +```go src=modules/bonfire/example_test.go#ExampleCheckDocs +commands := []bonfire.Command{ + { + Name: "blog:publish", + Description: "Publish a draft post", + Long: "Publishes the draft with the given slug and notifies subscribers.", + Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}}, + Examples: []bonfire.Example{{Command: "blog:publish hello-world"}}, + }, + { + Name: "blog:reindex", + Description: "Rebuild the search index", + Flags: []bonfire.Flag{{Name: "batch", Default: "500"}}, + }, +} +for _, problem := range bonfire.CheckDocs(commands) { + fmt.Println(problem) +} +// Output: blog:reindex: missing long help, examples, option --batch description +``` ## Calling commands in-process diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index fdb0de54..21a3f0f0 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -12,7 +12,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ ## Role in the framework -A base layer for console commands with no framework dependencies. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary. +A base layer for console commands; its only framework dependency is `sunscreen`, which scrubs credentials from error messages. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary. Use cases: @@ -32,7 +32,11 @@ Use cases: - Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description. - Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions. - Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook). -- Exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print as a block on the error stream (`bonfire.RenderError`); `bonfire.Fail` prints errors that happen before the root exists. +- Artisan-style help: `help ` and ` --help` print Description, Usage, Arguments, Options with `[default: ...]` and "(multiple values allowed)", the global options, Help (`bonfire.Command.Long`) and Examples (`bonfire.Command.Examples`, printed after `bin/` or `summer`). +- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`. +- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice). +- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions. +- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists. - Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking. - Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers. - In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command. @@ -98,7 +102,8 @@ if catalog.Has("blog:import") { | Identifier | Description | |------------|-------------| -| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, and the `bonfire.Command.Run` function. | +| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, the `bonfire.Command.Run` function, usage examples (`bonfire.Command.Examples`) and the `bonfire.Command.Hidden` switch. | +| `bonfire.Example` | One usage example: the command line without the binary (`Command`) and a short `Description`. | | `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. | | `bonfire.Arg` | A positional argument with a name, description and required marker. | | `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. | @@ -121,6 +126,19 @@ if catalog.Has("blog:import") { | `bonfire.RenderError` | Writes an error as the console error block. | | `bonfire.UsageError` | Marks a usage mistake (exit code 2); `bonfire.Usage` wraps an error in it. | | `bonfire.Usage` | Wraps an error as a `bonfire.UsageError`. | +| `bonfire.HintError` | An error with an `Explanation` and a `Try` text that the error block prints. | +| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/` or `summer`. | +| `bonfire.ErrReported` | Marks an error whose details the command already printed. | +| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, no error block. | +| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. | +| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. | +| `bonfire.CommandInfo` | One command of a `bonfire.Listing`: name, description, long help, usage, hidden and passthrough switches, `Arguments`, `Options` and `Examples`. | +| `bonfire.ArgumentInfo` | One argument of a `bonfire.CommandInfo`. | +| `bonfire.OptionInfo` | One flag of a `bonfire.CommandInfo`, or a global option, with its default. | +| `bonfire.ExampleInfo` | One example of a `bonfire.CommandInfo`. | +| `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. | +| `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. | +| `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads ": missing description, examples". | | `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. | | `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. | | `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. | @@ -148,9 +166,9 @@ Without these variables, color is enabled only when stdout is a terminal. ## Dependencies -- SummerCMS modules: none. +- SummerCMS modules: `sunscreen` (scrubs error messages). - Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). -- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. +- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. ## Testing diff --git a/modules/bonfire/command.go b/modules/bonfire/command.go index 5cee867f..99bbbef6 100644 --- a/modules/bonfire/command.go +++ b/modules/bonfire/command.go @@ -15,8 +15,8 @@ var ErrCommandName = errors.New("bonfire: plugin command name must be namespace: type Command struct { Name string Description string - // Long is optional extended help that cobra shows for the help command - // (help ). When empty, cobra falls back to Description. + // Long is optional extended help, shown under "Help:" by help + // and --help. Long string // Passthrough turns off flag parsing for the command: every argument // after the command name, including --help, -h and --, reaches Run @@ -29,6 +29,22 @@ type Command struct { Flags []Flag Args []Arg Run func(ctx context.Context, in Input, out Output) error + // Examples are shown under "Examples:" in the command's help and in + // the JSON listing, each as a "#" comment and the command line. + Examples []Example + // Hidden keeps a runnable command out of the list; list --all and the + // JSON listing still show it. + Hidden bool +} + +// Example is one usage example of a command. +type Example struct { + // Command is the command line without the binary, for example + // "admin:token:create jane --json"; help prints it after summer or + // bin/. + Command string + // Description is a short sentence shown as a "#" comment above it. + Description string } // Flag describes a string flag passed to a command. diff --git a/modules/bonfire/docs.go b/modules/bonfire/docs.go new file mode 100644 index 00000000..2ee4c69a --- /dev/null +++ b/modules/bonfire/docs.go @@ -0,0 +1,55 @@ +package bonfire + +import ( + "slices" + "strings" +) + +// DocProblem lists what one command's documentation lacks. +type DocProblem struct { + // Command is the command name. + Command string + // Missing names each gap: "description", "long help", "examples", + // "argument description" or "option -- description". + Missing []string +} + +// String formats the problem as ": missing description, examples". +func (p DocProblem) String() string { + return p.Command + ": missing " + strings.Join(p.Missing, ", ") +} + +// CheckDocs reports every command that lacks a description, long help, at +// least one example, or a description of one of its arguments or options, +// sorted by command name. It only reports: the framework's own test fails +// on a problem, plugin repositories log them. +func CheckDocs(commands []Command) []DocProblem { + var problems []DocProblem + for _, c := range commands { + var missing []string + if strings.TrimSpace(c.Description) == "" { + missing = append(missing, "description") + } + if strings.TrimSpace(c.Long) == "" { + missing = append(missing, "long help") + } + if len(c.Examples) == 0 { + missing = append(missing, "examples") + } + for _, a := range c.Args { + if strings.TrimSpace(a.Description) == "" { + missing = append(missing, "argument "+a.Name+" description") + } + } + for _, f := range c.Flags { + if strings.TrimSpace(f.Description) == "" { + missing = append(missing, "option --"+f.Name+" description") + } + } + if len(missing) > 0 { + problems = append(problems, DocProblem{Command: c.Name, Missing: missing}) + } + } + slices.SortStableFunc(problems, func(a, b DocProblem) int { return strings.Compare(a.Command, b.Command) }) + return problems +} diff --git a/modules/bonfire/errors.go b/modules/bonfire/errors.go index 1e4a4eb8..099431d0 100644 --- a/modules/bonfire/errors.go +++ b/modules/bonfire/errors.go @@ -2,9 +2,11 @@ package bonfire import ( "errors" - "fmt" "io" "strings" + + "git.golem15.com/golem15/summercms/modules/sunscreen" + "github.com/charmbracelet/colorprofile" ) // UsageError marks a mistake in how a command was called: an unknown @@ -12,6 +14,9 @@ import ( // for it. type UsageError struct { Err error + // command is the command that was called wrongly, when known; the + // error block suggests its help. + command string } func (e *UsageError) Error() string { return e.Err.Error() } @@ -39,14 +44,181 @@ func isUsage(err error) bool { return strings.HasPrefix(err.Error(), "unknown command") } -// RenderError writes err to w as the console error block: a blank line, -// " ERROR " and a blank line. name is the root name of the binary -// (summer, or the application binary's name). +// HintError adds an explanation and a command to try to an error. The error +// block prints both under the error message; {bin} in Try becomes summer or +// bin/. +type HintError struct { + Err error + Explanation string + Try string +} + +func (e *HintError) Error() string { return e.Err.Error() } + +func (e *HintError) Unwrap() error { return e.Err } + +// WithHint returns err with an explanation and a Try line (several lines are +// allowed); "{bin}" in try is replaced by how the binary is called. A nil err +// stays nil. +func WithHint(err error, explanation, try string) error { + if err == nil { + return nil + } + return &HintError{Err: err, Explanation: explanation, Try: try} +} + +// ErrReported marks an error whose details the command already printed: +// the error block prints nothing and the exit code is 1. +var ErrReported = errors.New("bonfire: error already reported") + +type reportedError struct { + err error +} + +func (e *reportedError) Error() string { return e.err.Error() } + +func (e *reportedError) Unwrap() error { return e.err } + +func (e *reportedError) Is(target error) bool { return target == ErrReported } + +// Reported returns an error that matches both ErrReported and err, for a +// command that has already printed its problems. A nil err gives +// ErrReported. +func Reported(err error) error { + if err == nil { + return ErrReported + } + return &reportedError{err: err} +} + +// RenderError writes err to w as the console error block: the ERROR badge +// and the message, the explanation and the Try lines of a HintError ({bin} +// replaced by summer or bin/, for the root named name), then "Run with +// -v for details." at verbosity 0 or the wrapped error chain under "Caused +// by:" at -v and above. cobra's unknown command error becomes `Command "x" +// is not defined.` with its suggestions as Try lines, and other usage errors +// suggest the command's help. Every message passes through sunscreen.Scrub. +// Errors wrapped by Reported print nothing. func RenderError(w io.Writer, name string, err error, g Globals) { - if err == nil || w == nil { + if err == nil || w == nil || errors.Is(err, ErrReported) { return } - _ = name - _ = g - fmt.Fprintf(w, "\n ERROR %s\n\n", err.Error()) + if cw, ok := w.(*colorprofile.Writer); ok { + w = cw.Forward + } + bin := usageName(name) + p := DefaultPalette() + message := err.Error() + explanation, try := "", "" + var hint *HintError + if errors.As(err, &hint) { + explanation, try = hint.Explanation, hint.Try + } + if strings.HasPrefix(message, "unknown command") { + cmdName, suggestions := parseUnknownCommand(message) + message = `Command "` + cmdName + `" is not defined.` + if try == "" { + var lines []string + for _, s := range suggestions { + lines = append(lines, "{bin} "+s) + } + if len(lines) == 0 { + lines = []string{"{bin} list"} + } + try = strings.Join(lines, "\n") + } + } else if try == "" { + var ue *UsageError + if errors.As(err, &ue) { + if ue.command != "" { + try = "{bin} help " + ue.command + } else { + try = "{bin} list" + } + } + } + + var b strings.Builder + b.WriteString("\n") + for i, line := range strings.Split(sunscreen.Scrub(strings.TrimRight(message, "\n")), "\n") { + if i == 0 { + b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n") + continue + } + b.WriteString(" " + line + "\n") + } + if explanation != "" { + b.WriteString("\n") + for _, line := range strings.Split(sunscreen.Scrub(explanation), "\n") { + b.WriteString(" " + line + "\n") + } + } + if try != "" { + b.WriteString("\n " + p.Header.Render("Try:") + "\n") + for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") { + b.WriteString(" " + p.Success.Render(sunscreen.Scrub(line)) + "\n") + } + } + if g.Verbosity > 0 { + if chain := causes(err, err.Error()); len(chain) > 0 { + b.WriteString("\n " + p.Header.Render("Caused by:") + "\n") + for _, c := range chain { + b.WriteString(" " + p.Muted.Render("- "+sunscreen.Scrub(c)) + "\n") + } + } + } else { + b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n") + } + b.WriteString("\n") + _ = writeStyled(w, g, b.String()) +} + +// parseUnknownCommand reads the command name and cobra's "Did you mean +// this?" suggestions from an unknown command error. +func parseUnknownCommand(msg string) (string, []string) { + name := "" + if i := strings.Index(msg, `"`); i >= 0 { + if j := strings.Index(msg[i+1:], `"`); j >= 0 { + name = msg[i+1 : i+1+j] + } + } + var suggestions []string + if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok { + for _, line := range strings.Split(rest, "\n") { + if s := strings.TrimSpace(line); s != "" { + suggestions = append(suggestions, s) + } + } + } + return name, suggestions +} + +// causes walks the wrapped errors below err (errors.Join members expanded) +// and returns each distinct message other than top. +func causes(err error, top string) []string { + var out []string + seen := map[string]bool{top: true} + var walk func(error) + walk = func(e error) { + for e != nil { + if msg := e.Error(); !seen[msg] { + seen[msg] = true + out = append(out, msg) + } + if joined, ok := e.(interface{ Unwrap() []error }); ok { + for _, m := range joined.Unwrap() { + walk(m) + } + return + } + e = errors.Unwrap(e) + } + } + walk(errors.Unwrap(err)) + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, m := range joined.Unwrap() { + walk(m) + } + } + return out } diff --git a/modules/bonfire/example_test.go b/modules/bonfire/example_test.go index 0ab41fec..a1709bf3 100644 --- a/modules/bonfire/example_test.go +++ b/modules/bonfire/example_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "git.golem15.com/golem15/summercms/modules/bonfire" ) @@ -51,3 +52,111 @@ func ExampleCatalog() { } // Output: publish hello-world, tags [news go], dry run true } + +func ExampleExample() { + export := bonfire.Command{ + Name: "blog:export", + Description: "Export published posts as JSON", + Long: "Writes every published post, newest first, to a JSON file.", + Flags: []bonfire.Flag{ + {Name: "path", Description: "Output file", Default: "posts.json"}, + }, + Examples: []bonfire.Example{ + {Command: "blog:export --path=backup.json", Description: "Export to another file"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + return nil + }, + } + // Hidden commands run, but list shows them only with --all. + cleanup := bonfire.Command{ + Name: "blog:cleanup", + Description: "Remove orphaned uploads", + Hidden: true, + } + + root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout) + if err != nil { + fmt.Println(err) + return + } + bonfire.Execute(context.Background(), root, []string{"help", "blog:export"}) + // Output: + // Description: + // Export published posts as JSON + // + // Usage: + // blog:export [options] + // + // Options: + // --path=PATH Output file [default: "posts.json"] + // + // Global options: + // -h, --help Display help for the given command + // -q, --quiet Do not output any message + // -v|vv|vvv Increase verbosity + // --ansi|no-ansi Force (or disable) ANSI output + // -n, --no-interaction Do not ask any interactive question + // --env The environment the command runs under + // + // Help: + // Writes every published post, newest first, to a JSON file. + // + // Examples: + // # Export to another file + // bin/acme blog:export --path=backup.json +} + +func ExampleWithHint() { + publish := bonfire.Command{ + Name: "blog:publish", + Description: "Publish a draft post", + Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + slug, _ := in.Argument("slug") + return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug), + "Only drafts can be published; published posts keep their slug.", + "{bin} blog:drafts") + }, + } + + root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout) + if err != nil { + fmt.Println(err) + return + } + code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"}) + fmt.Println("exit code:", code) + // Output: + // ERROR blog: no draft named "hello-world" + // + // Only drafts can be published; published posts keep their slug. + // + // Try: + // bin/acme blog:drafts + // + // Run with -v for details. + // + // exit code: 1 +} + +func ExampleCheckDocs() { + commands := []bonfire.Command{ + { + Name: "blog:publish", + Description: "Publish a draft post", + Long: "Publishes the draft with the given slug and notifies subscribers.", + Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}}, + Examples: []bonfire.Example{{Command: "blog:publish hello-world"}}, + }, + { + Name: "blog:reindex", + Description: "Rebuild the search index", + Flags: []bonfire.Flag{{Name: "batch", Default: "500"}}, + }, + } + for _, problem := range bonfire.CheckDocs(commands) { + fmt.Println(problem) + } + // Output: blog:reindex: missing long help, examples, option --batch description +} diff --git a/modules/bonfire/execute.go b/modules/bonfire/execute.go index f7259d94..787df263 100644 --- a/modules/bonfire/execute.go +++ b/modules/bonfire/execute.go @@ -7,7 +7,6 @@ import ( "strings" "charm.land/fang/v2" - "github.com/charmbracelet/colorprofile" "github.com/spf13/cobra" ) @@ -50,10 +49,9 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int { fang.WithoutManpage(), fang.WithoutCompletions(), fang.WithErrorHandler(func(w io.Writer, _ fang.Styles, err error) { - if cw, ok := w.(*colorprofile.Writer); ok { - // RenderError applies its own color profile. - w = cw.Forward - } + // fang hands over a colorprofile writer built from the + // environment only; RenderError unwraps it and applies + // --ansi and --no-ansi itself. RenderError(w, name, err, g) }), ) diff --git a/modules/bonfire/help.go b/modules/bonfire/help.go new file mode 100644 index 00000000..ee01d0d6 --- /dev/null +++ b/modules/bonfire/help.go @@ -0,0 +1,125 @@ +package bonfire + +import ( + "strconv" + "strings" +) + +// helpUsage is the usage line of a command in help and the JSON listing: +// the name, its arguments and [options]; passthrough commands end with +// [--] because everything after the name reaches them unparsed. +func helpUsage(c Command) string { + u := commandUsage(c) + " [options]" + if c.Passthrough { + u += " [--] " + } + return u +} + +// optionLabel is the left column of a flag: "-x, --name=NAME" for value +// flags, "--name" for bare flags, indented four spaces without shorthand. +func optionLabel(f Flag) string { + label := " " + if f.Shorthand != "" { + label = "-" + f.Shorthand + ", " + } + label += "--" + f.Name + if !f.Bare { + label += "=" + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + } + return label +} + +// optionDetail is a flag's description followed by its default and the +// repeatable marker. +func optionDetail(f Flag) string { + parts := []string{} + if f.Description != "" { + parts = append(parts, f.Description) + } + if f.Default != "" && !f.Bare { + if _, err := strconv.Atoi(f.Default); err == nil { + parts = append(parts, "[default: "+f.Default+"]") + } else { + parts = append(parts, "[default: "+strconv.Quote(f.Default)+"]") + } + } + if f.Repeatable { + parts = append(parts, "(multiple values allowed)") + } + return strings.Join(parts, " ") +} + +// renderHelp writes a command's help in the Artisan layout: Description, +// Usage, Arguments, Options with defaults, Global options, Help (the long +// text) and Examples, each section present only when it has content. +func (st *rootState) renderHelp(c Command, g Globals) error { + p := DefaultPalette() + var b strings.Builder + section := func(title string) { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(p.Header.Render(title+":") + "\n") + } + + if c.Description != "" { + section("Description") + b.WriteString(" " + c.Description + "\n") + } + section("Usage") + b.WriteString(" " + helpUsage(c) + "\n") + + width := 0 + for _, a := range c.Args { + width = max(width, len(a.Name)) + } + for _, f := range c.Flags { + width = max(width, len(optionLabel(f))) + } + row := func(label, detail string) { + line := " " + p.Success.Render(label) + if detail != "" { + line += strings.Repeat(" ", width-len(label)+2) + detail + } + b.WriteString(line + "\n") + } + if len(c.Args) > 0 { + section("Arguments") + for _, a := range c.Args { + row(a.Name, a.Description) + } + } + if len(c.Flags) > 0 { + section("Options") + for _, f := range c.Flags { + row(optionLabel(f), optionDetail(f)) + } + } + section("Global options") + writeGlobalOptions(&b, p) + if long := strings.TrimSpace(c.Long); long != "" { + section("Help") + for _, line := range strings.Split(long, "\n") { + if strings.TrimSpace(line) == "" { + b.WriteString("\n") + continue + } + b.WriteString(" " + line + "\n") + } + } + if len(c.Examples) > 0 { + section("Examples") + bin := usageName(st.name) + for i, e := range c.Examples { + if i > 0 { + b.WriteString("\n") + } + if e.Description != "" { + b.WriteString(" " + p.Muted.Render("# "+e.Description) + "\n") + } + b.WriteString(" " + bin + " " + e.Command + "\n") + } + } + return writeStyled(st.out, g, b.String()) +} diff --git a/modules/bonfire/help_smoke_test.go b/modules/bonfire/help_smoke_test.go new file mode 100644 index 00000000..ae77bd65 --- /dev/null +++ b/modules/bonfire/help_smoke_test.go @@ -0,0 +1,235 @@ +package bonfire + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func smokeTokenCommand() Command { + return Command{ + Name: "admin:token:create", + Description: "Create a personal API token for a backend admin", + Long: "Creates a token for the admin with the given login.\nThe token is printed once.", + Args: []Arg{{Name: "login", Description: "The admin's login", Required: true}}, + Flags: []Flag{ + {Name: "scopes", Description: "Comma-separated scopes", Default: "*"}, + {Name: "expires", Description: "Days until expiry", Default: "90"}, + {Name: "json", Description: "Print the token as JSON", Bare: true}, + {Name: "tag", Description: "Tag to add", Repeatable: true}, + }, + Examples: []Example{ + {Command: "admin:token:create jane --scopes=journal.*", Description: "Token for the MCP server, journal only"}, + {Command: "admin:token:create jane --json", Description: "Script-friendly output"}, + }, + Run: func(context.Context, Input, Output) error { return nil }, + } +} + +// smokeRoot returns a runner that executes args on a fresh root each time +// (pflag keeps a command's local flag values between executions). +func smokeRoot(t *testing.T, out *bytes.Buffer, commands ...Command) func(args ...string) int { + t.Helper() + return func(args ...string) int { + t.Helper() + root, err := NewRootIO("acme", commands, strings.NewReader(""), out, out, + WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"})) + if err != nil { + t.Fatal(err) + } + out.Reset() + return Execute(context.Background(), root, args) + } +} + +// TestHelpSmoke: --help renders the Artisan sections in order, +// with defaults, the repeatable marker and examples prefixed by bin/, +// and help prints the same. +func TestHelpSmoke(t *testing.T) { + var out bytes.Buffer + run := smokeRoot(t, &out, smokeTokenCommand()) + if code := run("admin:token:create", "--help"); code != ExitOK { + t.Fatalf("--help exit = %d\n%s", code, out.String()) + } + got := out.String() + order := []string{ + "Description:\n Create a personal API token for a backend admin\n", + "Usage:\n admin:token:create [options]\n", + "Arguments:\n login ", + "Options:\n", + ` --scopes=SCOPES Comma-separated scopes [default: "*"]`, + " --expires=EXPIRES Days until expiry [default: 90]", + " --json ", + "Tag to add (multiple values allowed)", + "Global options:\n -h, --help", + "Help:\n Creates a token for the admin with the given login.\n The token is printed once.\n", + "Examples:\n # Token for the MCP server, journal only\n bin/acme admin:token:create jane --scopes=journal.*\n\n # Script-friendly output\n bin/acme admin:token:create jane --json\n", + } + last := -1 + for _, want := range order { + if want == ` --scopes=SCOPES Comma-separated scopes [default: "*"]` || want == " --expires=EXPIRES Days until expiry [default: 90]" { + // The detail column is aligned to the longest label. + label, detail, _ := strings.Cut(strings.TrimSpace(want), " ") + i := strings.Index(got, label) + if i < 0 || !strings.Contains(got[i:], detail) { + t.Fatalf("help missing %q / %q:\n%s", label, detail, got) + } + continue + } + i := strings.Index(got, want) + if i < 0 { + t.Fatalf("help missing %q:\n%s", want, got) + } + if i < last { + t.Fatalf("%q out of order:\n%s", want, got) + } + last = i + } + if strings.Contains(got, "--json=") || strings.Contains(got, "[default: \"true\"]") { + t.Fatalf("bare flag rendered with a value:\n%s", got) + } + viaFlag := got + if code := run("help", "admin:token:create"); code != ExitOK { + t.Fatalf("help exit = %d", code) + } + if out.String() != viaFlag { + t.Fatalf("help differs from --help:\n%s\n---\n%s", out.String(), viaFlag) + } + if code := run("help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") { + t.Fatalf("bare help exit = %d\n%s", code, out.String()) + } +} + +// TestListJSONSmoke: list --format=json decodes into Listing with every +// command, a Hidden one flagged; the text list hides it unless --all. +func TestListJSONSmoke(t *testing.T) { + hidden := Command{Name: "acme:secret", Description: "A hidden maintenance command", Hidden: true, + Run: func(context.Context, Input, Output) error { return nil }} + var out bytes.Buffer + run := smokeRoot(t, &out, smokeTokenCommand(), hidden) + if code := run("list", "--format=json"); code != ExitOK { + t.Fatalf("json exit = %d\n%s", code, out.String()) + } + var l Listing + if err := json.Unmarshal(out.Bytes(), &l); err != nil { + t.Fatalf("decode: %v\n%s", err, out.String()) + } + if l.Application.Name != "acme" || l.Application.Version != "v1.4.0" || l.Application.Framework != "v0.3.2" { + t.Fatalf("application = %+v", l.Application) + } + if len(l.GlobalOptions) == 0 { + t.Fatal("no global options") + } + byName := map[string]CommandInfo{} + for _, c := range l.Commands { + byName[c.Name] = c + } + for _, name := range []string{"acme:secret", "admin:token:create", "list", "help"} { + if _, ok := byName[name]; !ok { + t.Fatalf("listing misses %s: %+v", name, l.Commands) + } + } + if !byName["acme:secret"].Hidden { + t.Fatal("hidden command not flagged") + } + tok := byName["admin:token:create"] + if len(tok.Arguments) != 1 || len(tok.Options) != 4 || len(tok.Examples) != 2 || tok.Options[1].Default != "90" { + t.Fatalf("token command = %+v", tok) + } + if !strings.Contains(out.String(), `"global_options"`) { + t.Fatal("json keys are not snake case") + } + + if code := run("list"); code != ExitOK || strings.Contains(out.String(), "acme:secret") { + t.Fatalf("list shows the hidden command (exit %d):\n%s", code, out.String()) + } + if code := run("list", "--all"); code != ExitOK || !strings.Contains(out.String(), "acme:secret") { + t.Fatalf("list --all hides the command (exit %d):\n%s", code, out.String()) + } + if code := run("acme:secret"); code != ExitOK { + t.Fatalf("hidden command does not run: %d", code) + } + if code := run("list", "--format=xml"); code != ExitUsage { + t.Fatalf("unknown format exit = %d", code) + } + + problems := CheckDocs([]Command{smokeTokenCommand(), hidden, {Name: "acme:bare", Args: []Arg{{Name: "x"}}, Flags: []Flag{{Name: "y"}}}}) + if len(problems) != 2 || problems[0].Command != "acme:bare" || problems[1].Command != "acme:secret" { + t.Fatalf("CheckDocs = %v", problems) + } + if got := problems[0].String(); got != "acme:bare: missing description, long help, examples, argument x description, option --y description" { + t.Fatalf("problem = %q", got) + } +} + +// TestErrorRenderSmoke: the error block shows hints with {bin} replaced, +// the -v hint or the wrapped chain, nothing for Reported errors, the exit +// codes, the unknown command suggestion and scrubbed secrets. +func TestErrorRenderSmoke(t *testing.T) { + inner := errors.New("open summer.yaml: no such file") + hinted := WithHint(fmt.Errorf("no application found in /tmp/x: %w", inner), + "summer make:command needs an application root (summer.yaml).", + "cd ..; {bin} make:command acme.blog:publish") + + var buf bytes.Buffer + RenderError(&buf, "acme", hinted, Globals{}) + got := buf.String() + for _, want := range []string{ + " ERROR no application found in /tmp/x: open summer.yaml: no such file\n", + "\n summer make:command needs an application root (summer.yaml).\n", + "\n Try:\n cd ..; bin/acme make:command acme.blog:publish\n", + "\n Run with -v for details.\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("block missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "Caused by:") { + t.Fatalf("chain shown at verbosity 0:\n%s", got) + } + buf.Reset() + RenderError(&buf, "summer", hinted, Globals{Verbosity: 1}) + got = buf.String() + if !strings.Contains(got, "Caused by:\n - open summer.yaml: no such file\n") || strings.Contains(got, "Run with -v") || !strings.Contains(got, "cd ..; summer make:command") { + t.Fatalf("verbose block:\n%s", got) + } + buf.Reset() + RenderError(&buf, "acme", errors.New("upstream said Bearer abcdefghijklmnop0123456789"), Globals{Verbosity: 1}) + if strings.Contains(buf.String(), "abcdefghijklmnop0123456789") { + t.Fatalf("bearer token not scrubbed:\n%s", buf.String()) + } + buf.Reset() + RenderError(&buf, "acme", Reported(errors.New("3 problems")), Globals{}) + if buf.Len() != 0 { + t.Fatalf("reported error printed: %q", buf.String()) + } + + var out bytes.Buffer + run := smokeRoot(t, &out, + Command{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error { return nil }}, + Command{Name: "docs:build", Description: "Build docs", Run: func(_ context.Context, _ Input, o Output) error { + o.Println("problem 1") + return Reported(errors.New("docs:build failed")) + }}, + ) + if code := run("docs:build"); code != ExitFailure || strings.Contains(out.String(), "ERROR") { + t.Fatalf("reported: exit %d\n%s", code, out.String()) + } + if code := run("migrat"); code != ExitUsage { + t.Fatalf("unknown command exit = %d", code) + } + got = out.String() + if !strings.Contains(got, `ERROR Command "migrat" is not defined.`) || !strings.Contains(got, "Try:\n bin/acme migrate\n") { + t.Fatalf("unknown command block:\n%s", got) + } + if code := run("migrate", "extra", "--bogus"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") { + t.Fatalf("unknown flag: exit %d\n%s", code, out.String()) + } + if code := run("help", "migrat"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") { + t.Fatalf("help unknown: exit %d\n%s", code, out.String()) + } +} diff --git a/modules/bonfire/list.go b/modules/bonfire/list.go index 4a2af9b3..8669e216 100644 --- a/modules/bonfire/list.go +++ b/modules/bonfire/list.go @@ -2,6 +2,7 @@ package bonfire import ( "context" + "encoding/json" "fmt" "io" "slices" @@ -17,15 +18,24 @@ func builtinCommands() []Command { { Name: "help", Description: "Display help for a command", + Long: "Prints the description, usage, arguments, options, long help and examples of a command. Without a command name it prints the command list.", Args: []Arg{{Name: "command_name", Description: "The command name"}}, + Examples: []Example{ + {Command: "help migrate", Description: "Help for the migrate command"}, + }, }, { Name: "list", Description: "List commands", + Long: "Prints the commands grouped by namespace. --all includes hidden commands; --format=json prints every command with its arguments, options and examples.", Flags: []Flag{ {Name: "all", Description: "Show hidden commands too", Bare: true}, {Name: "format", Description: "The output format (txt or json)", Default: "txt"}, }, + Examples: []Example{ + {Command: "list --all", Description: "Every command, hidden ones included"}, + {Command: "list --format=json", Description: "The catalog for scripts and agents"}, + }, }, } } @@ -48,45 +58,71 @@ func usageName(name string) string { return "bin/" + name } -// lister renders the command list of one root. -type lister struct { - name string - commands []Command - version Version - out io.Writer - console *console -} - -// command is the list command with its run function. -func (l *lister) command() Command { +// listCommand is the list command with its run function. +func (st *rootState) listCommand() Command { c := builtinCommand("list") c.Run = func(_ context.Context, in Input, _ Output) error { all, _ := in.Flag("all") format, _ := in.Flag("format") switch format { case "", "txt": - return l.render(l.console.globals, all == "true") + return st.renderList(st.globals(), all == "true") + case "json": + return st.renderJSON() default: - return Usage(fmt.Errorf("list: unknown format %q (use txt or json)", format)) + return WithHint(Usage(fmt.Errorf("list: unknown format %q", format)), + "list prints txt (the default) or json.", "{bin} list --format=json") } } return c } -// all returns the root's commands and its builtins. -func (l *lister) all() []Command { - return append(append([]Command(nil), l.commands...), builtinCommands()...) +// helpCommand is the help command: the list without an argument, a +// command's help with one. +func (st *rootState) helpCommand() Command { + c := builtinCommand("help") + c.Run = func(_ context.Context, in Input, _ Output) error { + args := in.Args() + if len(args) == 0 { + return st.renderList(st.globals(), false) + } + name := args[0] + for _, cmd := range st.all() { + if cmd.Name == name { + return st.renderHelp(cmd, st.globals()) + } + } + err := Usage(fmt.Errorf("Command %q is not defined.", name)) + var try []string + for _, s := range st.root.SuggestionsFor(name) { + try = append(try, "{bin} help "+s) + } + if len(try) == 0 { + try = []string{"{bin} list"} + } + return WithHint(err, "", strings.Join(try, "\n")) + } + return c } -// render writes the Artisan list: the version header, usage, the global +// all returns the root's commands and its builtins. +func (st *rootState) all() []Command { + return append(append([]Command(nil), st.commands...), builtinCommands()...) +} + +// renderList writes the Artisan list: the version header, usage, the global // options and the commands, top-level names first and then one block per -// namespace (the text before the first colon). -func (l *lister) render(g Globals, all bool) error { +// namespace (the text before the first colon). Hidden commands are left out +// unless all is true. +func (st *rootState) renderList(g Globals, all bool) error { p := DefaultPalette() var top []Command groups := map[string][]Command{} width := 0 - for _, c := range l.all() { + for _, c := range st.all() { + if c.Hidden && !all { + continue + } ns, _, namespaced := strings.Cut(c.Name, ":") if namespaced { groups[ns] = append(groups[ns], c) @@ -104,9 +140,9 @@ func (l *lister) render(g Globals, all bool) error { slices.Sort(namespaces) var b strings.Builder - b.WriteString(p.Header.Render(l.version.Header(l.name)) + "\n\n") + b.WriteString(p.Header.Render(st.version.Header(st.name)) + "\n\n") b.WriteString(p.Header.Render("Usage:") + "\n") - b.WriteString(" " + usageName(l.name) + " [options] [arguments]\n\n") + b.WriteString(" " + usageName(st.name) + " [options] [arguments]\n\n") b.WriteString(p.Header.Render("Options:") + "\n") writeGlobalOptions(&b, p) b.WriteString("\n" + p.Header.Render("Available commands:") + "\n") @@ -125,7 +161,17 @@ func (l *lister) render(g Globals, all bool) error { line(c) } } - return writeStyled(l.out, g, b.String()) + return writeStyled(st.out, g, b.String()) +} + +// renderJSON writes Describe of the root as indented JSON. +func (st *rootState) renderJSON() error { + data, err := json.MarshalIndent(Describe(st.name, st.all(), st.version), "", " ") + if err != nil { + return err + } + _, err = st.out.Write(append(data, '\n')) + return err } // writeGlobalOptions writes the global option lines, aligned. @@ -147,3 +193,112 @@ func writeStyled(w io.Writer, g Globals, s string) error { _, err := io.WriteString(cw, s) return err } + +// Listing is the JSON catalog of a binary's commands (list --format=json). +type Listing struct { + Application ApplicationInfo `json:"application"` + GlobalOptions []OptionInfo `json:"global_options"` + Commands []CommandInfo `json:"commands"` +} + +// ApplicationInfo names the binary and its versions in a Listing. +type ApplicationInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Framework string `json:"framework"` +} + +// CommandInfo is one command of a Listing. +type CommandInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Long string `json:"long"` + Usage string `json:"usage"` + Hidden bool `json:"hidden"` + Passthrough bool `json:"passthrough"` + Arguments []ArgumentInfo `json:"arguments"` + Options []OptionInfo `json:"options"` + Examples []ExampleInfo `json:"examples"` +} + +// ArgumentInfo is one positional argument of a CommandInfo. +type ArgumentInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` +} + +// OptionInfo is one flag of a CommandInfo or a global option. +type OptionInfo struct { + Name string `json:"name"` + Shorthand string `json:"shorthand"` + Description string `json:"description"` + Default string `json:"default"` + Bare bool `json:"bare"` + Repeatable bool `json:"repeatable"` +} + +// ExampleInfo is one example of a CommandInfo, the command line without the +// binary. +type ExampleInfo struct { + Command string `json:"command"` + Description string `json:"description"` +} + +// globalOptionInfos are the global options as Listing entries. +var globalOptionInfos = []OptionInfo{ + {Name: flagHelp, Shorthand: "h", Description: "Display help for the given command", Bare: true}, + {Name: flagQuiet, Shorthand: "q", Description: "Do not output any message", Bare: true}, + {Name: flagVerbose, Shorthand: "v", Description: "Increase verbosity (-v, -vv or -vvv)", Bare: true, Repeatable: true}, + {Name: flagANSI, Description: "Force ANSI output", Bare: true}, + {Name: flagNoANSI, Description: "Disable ANSI output", Bare: true}, + {Name: flagNoInteraction, Shorthand: "n", Description: "Do not ask any interactive question", Bare: true}, + {Name: flagEnv, Description: "The environment the command runs under"}, +} + +// Describe returns the Listing of a binary named name with the given +// commands, sorted by name: every command, hidden ones included, with its +// arguments, options and examples. NewRoot's list --format=json adds the +// list and help commands. +func Describe(name string, commands []Command, v Version) Listing { + l := Listing{ + Application: ApplicationInfo{ + Name: name, + Version: cleanVersion(v.App), + Framework: cleanVersion(v.Framework), + }, + GlobalOptions: append([]OptionInfo(nil), globalOptionInfos...), + Commands: make([]CommandInfo, 0, len(commands)), + } + if v.Framework == "" { + l.Application.Framework = l.Application.Version + } + for _, c := range commands { + info := CommandInfo{ + Name: c.Name, + Description: c.Description, + Long: c.Long, + Usage: helpUsage(c), + Hidden: c.Hidden, + Passthrough: c.Passthrough, + Arguments: []ArgumentInfo{}, + Options: []OptionInfo{}, + Examples: []ExampleInfo{}, + } + for _, a := range c.Args { + info.Arguments = append(info.Arguments, ArgumentInfo{Name: a.Name, Description: a.Description, Required: a.Required}) + } + for _, f := range c.Flags { + info.Options = append(info.Options, OptionInfo{ + Name: f.Name, Shorthand: f.Shorthand, Description: f.Description, + Default: f.Default, Bare: f.Bare, Repeatable: f.Repeatable, + }) + } + for _, e := range c.Examples { + info.Examples = append(info.Examples, ExampleInfo{Command: e.Command, Description: e.Description}) + } + l.Commands = append(l.Commands, info) + } + slices.SortFunc(l.Commands, func(a, b CommandInfo) int { return strings.Compare(a.Name, b.Name) }) + return l +} diff --git a/modules/bonfire/root.go b/modules/bonfire/root.go index 50dfaf00..2e9b74de 100644 --- a/modules/bonfire/root.go +++ b/modules/bonfire/root.go @@ -97,10 +97,19 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write root.SetErr(errW) flags := &globalFlags{} flags.register(root.PersistentFlags()) - root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { - return Usage(err) + root.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + return usageFor(c, root, err) }) + st := &rootState{ + root: root, + name: name, + commands: commands, + version: cfg.version, + out: out, + console: output, + flags: flags, + } seen := map[string]bool{} for _, b := range builtinCommands() { seen[b.Name] = true @@ -110,21 +119,28 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write return nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name) } seen[command.Name] = true - cmd, err := wrap(command, output) + cmd, err := st.wrap(command, true) if err != nil { return nil, err } root.AddCommand(cmd) } - lister := &lister{name: name, commands: commands, version: cfg.version, out: out, console: output} - listCmd, err := wrapCommand(lister.command(), output, false) + listCmd, err := st.wrap(st.listCommand(), false) if err != nil { return nil, err } root.AddCommand(listCmd) + helpCmd, err := st.wrap(st.helpCommand(), false) + if err != nil { + return nil, err + } + root.SetHelpCommand(helpCmd) + root.SetHelpFunc(func(*cobra.Command, []string) { + _ = st.renderList(st.globals(), false) + }) root.RunE = func(cmd *cobra.Command, _ []string) error { - return lister.render(output.globals, false) + return st.renderList(st.globals(), false) } root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { var g Globals @@ -144,6 +160,36 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write return root, nil } +// rootState is what the list, help and error rendering of one root share. +type rootState struct { + root *cobra.Command + name string + commands []Command + version Version + out io.Writer + console *console + flags *globalFlags +} + +// globals returns the parsed global options of the current run. +func (st *rootState) globals() Globals { + return st.flags.globals() +} + +// wrap turns a command value into a cobra command. validate checks the +// name form (the builtins list and help skip it). +func (st *rootState) wrap(c Command, validate bool) (*cobra.Command, error) { + cmd, err := wrapCommand(c, st.console, validate) + if err != nil { + return nil, err + } + cmd.Hidden = c.Hidden + cmd.SetHelpFunc(func(*cobra.Command, []string) { + _ = st.renderHelp(c, st.globals()) + }) + return cmd, nil +} + func wrap(c Command, out Output) (*cobra.Command, error) { return wrapCommand(c, out, true) } @@ -211,6 +257,23 @@ func wrapCommand(c Command, out Output, validate bool) (*cobra.Command, error) { // usageArgs marks argument-count failures as usage errors (exit code 2). func usageArgs(fn cobra.PositionalArgs) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { - return Usage(fn(cmd, args)) + return usageFor(cmd, cmd.Root(), fn(cmd, args)) } } + +// usageFor wraps err as a UsageError that remembers the command, so the +// error block can suggest its help. +func usageFor(cmd, root *cobra.Command, err error) error { + if err == nil { + return nil + } + var ue *UsageError + if errors.As(err, &ue) { + return err + } + ue = &UsageError{Err: err} + if cmd != nil && cmd != root { + ue.command = cmd.Name() + } + return ue +}