feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --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
This commit is contained in:
@@ -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 <command>` and `<command> --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/<name>` 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/<name>` 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/<name>` 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 "<command>: 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
|
||||
|
||||
|
||||
@@ -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 <command>). When empty, cobra falls back to Description.
|
||||
// Long is optional extended help, shown under "Help:" by help
|
||||
// <command> and <command> --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/<name>.
|
||||
Command string
|
||||
// Description is a short sentence shown as a "#" comment above it.
|
||||
Description string
|
||||
}
|
||||
|
||||
// Flag describes a string flag passed to a command.
|
||||
|
||||
55
modules/bonfire/docs.go
Normal file
55
modules/bonfire/docs.go
Normal file
@@ -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 <name> description" or "option --<name> description".
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// String formats the problem as "<command>: 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
|
||||
}
|
||||
@@ -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 <message>" 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/<name>.
|
||||
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/<name>, 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
125
modules/bonfire/help.go
Normal file
125
modules/bonfire/help.go
Normal file
@@ -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
|
||||
// [--] <args...> because everything after the name reaches them unparsed.
|
||||
func helpUsage(c Command) string {
|
||||
u := commandUsage(c) + " [options]"
|
||||
if c.Passthrough {
|
||||
u += " [--] <args...>"
|
||||
}
|
||||
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())
|
||||
}
|
||||
235
modules/bonfire/help_smoke_test.go
Normal file
235
modules/bonfire/help_smoke_test.go
Normal file
@@ -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: <command> --help renders the Artisan sections in order,
|
||||
// with defaults, the repeatable marker and examples prefixed by bin/<name>,
|
||||
// and help <command> 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 <login> [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 <command> differs from <command> --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())
|
||||
}
|
||||
}
|
||||
@@ -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) + " <command> [options] [arguments]\n\n")
|
||||
b.WriteString(" " + usageName(st.name) + " <command> [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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user