- migrate, migrate:rollback and deferred:purge gain --pretend and --force; they and queue:clear confirm only in production on an interactive terminal, so deploys over SSH proceed as before - result lines: Applied N migrations, Rolled back <id> of <plugin>, Cleared N jobs from <queue> - --json for migrate:status, key:generate, route:list, admin:token:create, admin:token:list, admin:token:scopes and websockets:health; -q keeps the key and the token - hints for an empty DSN, an unreachable database, an invalid app.key, a missing uploads bucket and an application binary run below its root - flare and websockets:health return bonfire.Reported after printing - long help and examples for every application command, greeter:hello and blog:publish; cmd/summer TestCommandsDocumented fails on any gap
14 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Writing commands | Add console commands to a plugin with bonfire.Command values, arguments, flags, styled output and prompts, and run commands in-process. | console | 40 |
Writing commands
A plugin adds console commands to the application binary the way a WinterCMS plugin calls registerConsoleCommand. In SummerCMS a command is a plain bonfire.Command value, and the plugin returns its commands from pact.HasCommands. summer make:command acme.blog Publish generates a starting point in console/publish.go.
Defining a command
A bonfire.Command has a name, a description, its positional arguments and flags, and a run function:
- The name is in
namespace:verbform, such asblog:publish. Plugin commands must use this form; only a few framework commands have bare names. - Each
bonfire.Argis a positional argument with a name, a description and aRequiredmarker. The usage line shows required arguments as<name>and optional ones as[name]. - Each
bonfire.Flagis a string flag. Setbonfire.Flag.Barefor a switch such as--dry-runthat storestruewhen given alone, andbonfire.Flag.Repeatablefor a flag that can be given several times. bonfire.Command.Runreceives the context, abonfire.Inputand abonfire.Output.bonfire.Command.Longis optional extended help, shown under "Help:" byhelp <command>and<command> --help.bonfire.Command.Examplesandbonfire.Command.Hiddenare described below. Setbonfire.Command.Passthroughfor a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including--help,-hand--, reachesbonfire.Input.Argsunchanged. 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:
publish := bonfire.Command{
Name: "blog:publish",
Description: "Publish a post",
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
Flags: []bonfire.Flag{
{Name: "dry-run", Description: "Report without writing", Bare: true},
{Name: "tag", Description: "Tag to add (repeatable)", Repeatable: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
slug, _ := in.Argument("slug")
dryRun, _ := in.Flag("dry-run")
out.Printf("publish %s, tags %v, dry run %s\n", slug, in.Flags("tag"), dryRun)
return nil
},
}
// The generated main publishes a catalog of every command on the app.
catalog := bonfire.NewCatalog([]bonfire.Command{publish})
args := []string{"hello-world", "--tag", "news", "--tag", "go", "--dry-run"}
if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); err != nil {
fmt.Println(err)
}
// Output: publish hello-world, tags [news go], dry run true
Registering commands
Return the commands from the plugin's Commands method, which implements pact.HasCommands. The generated main appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named list or help, bonfire.NewRoot fails with bonfire.ErrDuplicateCommand naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as blog:. The global options are reserved as well: a flag named verbose, quiet, ansi, no-ansi, no-interaction, env or help, or with the shorthand v, q, n or h, makes bonfire.NewRoot fail with bonfire.ErrReservedFlag.
A scaffolded plugin's Commands method returns the generated list of everything in console/, so commands created with summer make:command are registered without editing plugin.go.
Output
bonfire.Output is the console your command writes to. Besides bonfire.Output.Printf and bonfire.Output.Println, it provides:
- status lines:
bonfire.Output.Info,bonfire.Output.Success,bonfire.Output.Warningandbonfire.Output.Error(the last one writes to the error stream); - widgets:
bonfire.Output.Table,bonfire.Output.Spinneraround a function andbonfire.Output.Progressfor a progress bar. On a terminal they are SSU's live views on the charm stack: a spinner, a gradient bar and boxed tables; - prompts:
bonfire.Output.Ask,bonfire.Output.Confirm,bonfire.Output.Choiceandbonfire.Output.Secret. On an interactive terminal they are huh fields and an arrow-key selector; otherwise they read lines from stdin, so a password can be piped in, and return their defaults when input ends, so a command run from cron or a script never hangs.
How much of this appears depends on where the command runs and on the global options:
| 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 | printed, tables tab-separated | read a line from stdin; Confirm returns its default |
-q |
none | info, success and warning lines hidden; errors kept | printed | 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 |
Print the data your command exists to produce (a token, a key, JSON) with bonfire.Output.Println or bonfire.Output.Printf, never as a status line: -q keeps that output and drops only the decoration around it. bonfire.IsQuiet, bonfire.VerbosityOf and bonfire.GlobalsOf tell a command which options it runs under.
Before a destructive step, ask with bonfire.ConfirmProceed. It asks only on an interactive terminal; off a terminal, with -n or -q, and when the scheduler calls the command, it returns true without asking, so deploys over SSH never wait for an answer.
For a loop over many items, bonfire.Process runs a function per item and shows SSU's process view on a terminal, one row per item with its state, or prints one ✓ item or ✗ item: error line per item elsewhere; it returns the failures joined. Inside bonfire.Output.Progress, bonfire.Current names the item being worked on and bonfire.Failed records a failed one. Log lines and output printed while a live view runs appear above the view (bonfire.PrintAbove).
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.
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/<name> (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:
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/<name> or summer, so the hint names the right program:
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 <command>; wrap your own usage checks with bonfire.Usage to get the same exit code.
An application binary also explains the most common mistake: running it from the wrong directory. When a command fails, the working directory has no config/ directory and a parent directory holds summer.yaml, the block says "This command runs from the application root." and suggests the same command from there:
ERROR lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)
This command runs from the application root.
Try:
cd ../..; bin/acme migrate
Only the message changes: the binary still reads its configuration from config/ in the working directory. summer is not affected, because its commands find the application root themselves.
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:
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
bonfire.Call runs one command of a slice by name with its arguments and writes the output to any writer, like Artisan::call in Laravel. Tests use it to exercise a command without building a binary:
commands := []bonfire.Command{{
Name: "acme:greet",
Description: "Greet someone by name",
Args: []bonfire.Arg{{Name: "name", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name, _ := in.Argument("name")
out.Printf("Hello, %s\n", name)
return nil
},
}}
if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
fmt.Println(err)
}
// Output: Hello, blog
The generated main also publishes the application's complete command list as a bonfire.Catalog on the container. Code outside the command line, such as the scheduler, looks it up and calls commands through bonfire.Catalog.Call, and checks for one with bonfire.Catalog.Has.
Running your command
After summer build, your command is part of the binary:
./bin/hello greeter:hello
That command comes from the greeter plugin of examples/hello, whose Commands method returns one bonfire.Command.