- while a view runs, runLive relays SIGINT and SIGTERM (unless ignored); on a signal it kills the program, which shows the cursor and restores the terminal, unregisters the view, stops relaying and raises the same signal again, so the default action or the command's own handler still applies - the generated main, bonfire.Execute's context and Ctrl+C for commands without a live view are unchanged - TestLiveViewRestoresTerminalOnSignal pins both paths in a re-executed helper process (WR-01)
203 lines
19 KiB
Markdown
203 lines
19 KiB
Markdown
# bonfire
|
|
|
|
Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/bonfire"`
|
|
|
|
**Name:** The evening fire where everyone gathers; the command line is where you sit down and fire off commands.
|
|
|
|
## Overview
|
|
|
|
bonfire is the console layer of SummerCMS. Plugins and framework modules describe commands as plain `bonfire.Command` values (name, flags, arguments and a run function), and `bonfire.NewRoot` turns a slice of them into a Cobra root command. Commands never touch Cobra directly: they read arguments through `bonfire.Input` and write through `bonfire.Output`, which also provides tables, spinners, progress bars and interactive prompts. It is the counterpart of WinterCMS's artisan console commands (`registerConsoleCommand` and Laravel's `Illuminate\Console\Command` output helpers).
|
|
|
|
## Role in the framework
|
|
|
|
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:
|
|
|
|
- Add a plugin command such as blog:import with typed arguments and options.
|
|
- Ask for confirmation or input interactively and print styled tables and progress.
|
|
- Ship operational commands (backfills, health checks) inside the same binary as the application.
|
|
|
|
## Features
|
|
|
|
- Command values with a description, positional arguments (`bonfire.Arg`) and string flags (`bonfire.Flag`), collected from plugins or the tool itself.
|
|
- Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `new`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`.
|
|
- Usage strings and argument-count checks derived from the declared arguments (`<name>` for required, `[name]` for optional).
|
|
- Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`).
|
|
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
|
|
- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers.
|
|
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal.
|
|
- Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins:
|
|
|
|
| Run | Animation | Decoration | Data | Prompts |
|
|
|-----|-----------|------------|------|---------|
|
|
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
|
|
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors unless `FORCE_COLOR` | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
|
|
| `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` |
|
|
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
|
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
|
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`.
|
|
- 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).
|
|
- 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. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
|
|
- 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.
|
|
|
|
## Usage
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
)
|
|
|
|
func main() {
|
|
importPosts := bonfire.Command{
|
|
Name: "blog:import",
|
|
Description: "Import posts from a feed",
|
|
Args: []bonfire.Arg{{Name: "url", Description: "Feed URL", Required: true}},
|
|
Flags: []bonfire.Flag{
|
|
{Name: "dry-run", Description: "Report without writing", Bare: true},
|
|
{Name: "tag", Description: "Tag to apply (repeatable)", Repeatable: true},
|
|
},
|
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
|
url, _ := in.Argument("url")
|
|
_, dryRun := in.Flag("dry-run")
|
|
return out.Spinner("Importing "+url, func() error {
|
|
out.Table([]string{"Tag"}, [][]string{{"news"}})
|
|
if dryRun {
|
|
out.Warning("dry run: nothing written")
|
|
}
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
|
|
args := os.Args[1:]
|
|
g := bonfire.PeekGlobals(args)
|
|
root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout,
|
|
bonfire.WithVersion(bonfire.BuildVersion()))
|
|
if err != nil {
|
|
os.Exit(bonfire.Fail(os.Stderr, "acme", err, g))
|
|
}
|
|
os.Exit(bonfire.Execute(context.Background(), root, args))
|
|
}
|
|
```
|
|
|
|
Running a command in-process, with empty stdin so prompts take their defaults:
|
|
|
|
```go
|
|
catalog := bonfire.NewCatalog(commands)
|
|
if catalog.Has("blog:import") {
|
|
err := catalog.Call(ctx, "blog:import", []string{"--dry-run", "https://example.com/feed"}, os.Stdout)
|
|
if errors.Is(err, bonfire.ErrUnknownCommand) {
|
|
// not registered in this binary
|
|
}
|
|
}
|
|
```
|
|
|
|
## API reference
|
|
|
|
| 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, 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`. |
|
|
| `bonfire.Output` | Injected console: printing, status lines, tables, spinner, progress bar and prompts. |
|
|
| `bonfire.Progress` | A progress bar advanced from inside `bonfire.Output.Progress`. |
|
|
| `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin, with the global options, the `list` command and the command list on a bare call; takes `bonfire.RootOption` values. When the output is the process stdout, the error block goes to stderr. |
|
|
| `bonfire.NewRootIO` | `bonfire.NewRoot` with injected stdin, stdout and stderr. |
|
|
| `bonfire.RootOption` | An option of `bonfire.NewRoot` and `bonfire.NewRootIO`. |
|
|
| `bonfire.WithVersion` | Sets the `bonfire.Version` shown in the list header; without it the root reads `bonfire.BuildVersion`. |
|
|
| `bonfire.OnGlobals` | Registers a function that receives the parsed `bonfire.Globals` before every command of the root runs. |
|
|
| `bonfire.Globals` | The global options: `Verbosity` (0 to 3), `Quiet`, `Color` (a `bonfire.ColorMode`), `NoInteraction` and `Env`. |
|
|
| `bonfire.PeekGlobals` | Reads `bonfire.Globals` from raw arguments without failing; stops at `--`. |
|
|
| `bonfire.Globals.Args` | Turns `bonfire.Globals` back into flags (`-vv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=<value>`) for forwarding. |
|
|
| `bonfire.Version` | Application and framework versions of a binary (`App`, `Framework`, `Local`). |
|
|
| `bonfire.BuildVersion` | Reads the `bonfire.Version` from the binary's build information. |
|
|
| `bonfire.Version.Header` | The list header: `SummerCMS acme v1.4.0 (framework v0.3.2)`. |
|
|
| `bonfire.Execute` | Runs a root with arguments through fang and returns the exit code; prints the list when no command is given. |
|
|
| `bonfire.Fail` | Renders an error that happened before the root exists and returns `bonfire.ExitFailure`. |
|
|
| `bonfire.ExitOK`, `bonfire.ExitFailure`, `bonfire.ExitUsage` | The exit codes 0, 1 and 2. |
|
|
| `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.ItemProgress` | The optional side of a `bonfire.Progress` that names the current item (`Current`) and records failures (`Fail`); `bonfire.Output.Progress` hands one over. |
|
|
| `bonfire.Current` | Names the current item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
|
| `bonfire.Failed` | Records a failed item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
|
| `bonfire.Process` | Runs a function per item with the process view on a terminal, or one `✓`/`✗` line per item; returns the joined failures. |
|
|
| `bonfire.PrintAbove` | Prints a line above the running live view and reports whether one was running. |
|
|
| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). |
|
|
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
|
|
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
|
|
| `bonfire.IsTerminal` | Whether the Output writes to a terminal; long-running commands print a summary block for a person there and log records otherwise. |
|
|
| `bonfire.Details`, `bonfire.Detail` | Prints a title and aligned label/value lines in the palette (a `Warn` value in the warning color); nothing under `-q`. Used for startup summaries and shutdown reports. |
|
|
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
|
|
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
|
|
| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. |
|
|
| `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. |
|
|
| `bonfire.ErrCommandName` | Returned when a plugin command name is not in `namespace:verb` form. |
|
|
| `bonfire.Call` | Runs one command of a slice by exact name with arguments, writing output to a writer; stdin is empty. |
|
|
| `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. |
|
|
| `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. |
|
|
| `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. |
|
|
| `bonfire.ColorMode` | How console output decides on color: `bonfire.ColorAuto` (terminal and environment), `bonfire.ColorAlways` or `bonfire.ColorNever`. |
|
|
| `bonfire.Palette` | The console styles (`Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`), shared with the `diary` console log lines. |
|
|
| `bonfire.DefaultPalette` | Returns the palette ported from SSU's status styles: green success, cyan current, yellow warning, red and bold red errors, gray muted text, bold headers. |
|
|
| `bonfire.DetectProfile` | Returns the color profile for a writer under a `bonfire.ColorMode`, honouring `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR`; render palette styles through a `colorprofile.Writer` with it. |
|
|
|
|
## Configuration
|
|
|
|
bonfire reads no config keys. Output color follows these environment variables:
|
|
|
|
| Variable | Effect |
|
|
|----------|--------|
|
|
| `NO_COLOR` | Any non-empty value disables color. |
|
|
| `TERM` | The value `dumb` disables color. |
|
|
| `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). |
|
|
|
|
Without these variables, color is enabled only when stdout is a terminal. `--ansi` forces color and `--no-ansi` turns it off whatever the environment says.
|
|
|
|
## Dependencies
|
|
|
|
- SummerCMS modules: `sunscreen` (scrubs error messages).
|
|
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/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`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test ./modules/bonfire/...
|
|
```
|
|
|
|
The tests use in-memory streams and need no external services.
|