feat(summer): help, examples and hints for every tool command

- build runs go build under a spinner on a terminal and ends with
  'Built bin/<name> in <time>'; make:* print 'Created <path>' per file
- make:*, plugin:add, new, parity:*, docs:build, docs:sync and every
  delegate get long help and examples; delegates note that global options
  pass through to the application binary
- make:* failures (no summer.yaml, invalid plugin ID or name, existing
  artifact, unknown plugin) explain themselves and suggest a command
- docs:build and docs:sync return bonfire.Reported after the problem list
- TestCommandsDocumented covers the tool commands too
- docs: Upgrading to 0.4, delegates and global options, make: results and
  hints, the checklist for a finished command
This commit is contained in:
Jakub Zych
2026-10-21 13:25:53 +02:00
parent 9d06a78f62
commit 33460607db
14 changed files with 385 additions and 50 deletions

View File

@@ -31,10 +31,11 @@ func applicationCommands() []bonfire.Command {
}
// TestCommandsDocumented is the framework's documentation coverage gate
// (D-17): every framework command has a description, long help, examples
// and described arguments and options, and the test fails on any gap.
// (D-17): every command of the summer tool (its delegates included) and of
// an application binary has a description, long help, examples and
// described arguments and options, and the test fails on any gap.
func TestCommandsDocumented(t *testing.T) {
commands := applicationCommands()
commands := append(toolCommands(), applicationCommands()...)
for _, problem := range bonfire.CheckDocs(commands) {
t.Error(problem)
}

View File

@@ -2,7 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"sync"
@@ -24,6 +24,17 @@ func docsBuildCommand() bonfire.Command {
return bonfire.Command{
Name: "docs:build",
Description: "Build the documentation site",
Long: `Checks every page under docs/ and every module README, then writes the static
site to site/ (or --out): HTML pages, a raw .md copy of each page, llms.txt,
llms-full.txt and the search index. It fails, writing nothing, when a page
names an identifier that does not exist, links to a missing page or anchor,
shows a command neither summer nor an application binary has, or has a src=
code block that differs from its source. --check only reports. Run it from
the framework root.`,
Examples: []bonfire.Example{
{Command: "docs:build --check", Description: "Check the docs and write nothing"},
{Command: "docs:build --base-url /docs/ --out /tmp/site", Description: "Build the site for /docs/ into /tmp/site"},
},
Flags: []bonfire.Flag{
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
@@ -63,6 +74,14 @@ func docsSyncCommand() bonfire.Command {
return bonfire.Command{
Name: "docs:sync",
Description: "Rewrite src= code blocks from their sources",
Long: `Rewrites every code block that has a src= reference with the current text of
its source (a file, or a function, test or example in it), so the docs show
what the code does. Run it after changing code a page shows; docs:build
--check fails until you do.`,
Examples: []bonfire.Example{
{Command: "docs:sync", Description: "Refresh every src= block of the docs in this repository"},
{Command: "docs:sync --root ../summercms.go", Description: "Refresh the docs of another checkout"},
},
Flags: []bonfire.Flag{
{Name: "root", Description: "Repository root; src= paths resolve against it", Default: "."},
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
@@ -174,11 +193,11 @@ func docsCommands() *docsite.Commands {
}
// reportDocsProblems prints one line per problem and a summary, and returns
// a short error so the binary exits 1.
// a Reported error, so the binary exits 1 without repeating the list.
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
for _, p := range problems {
out.Printf("%s\n", p)
}
out.Printf("%s: %d problems, nothing written\n", cmd, len(problems))
return errors.New(cmd + " failed")
return bonfire.Reported(fmt.Errorf("%s failed", cmd))
}

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
@@ -54,12 +55,39 @@ func buildCommand() bonfire.Command {
return bonfire.Command{
Name: "build",
Description: "Generate plugin imports and build the app binary",
Long: `Reads summer.yaml of the application in the current directory (or the nearest
one above it), generates plugins.gen.go and main.go from it, and runs go build
into bin/<binary>, passing the manifest's build.tags as -tags. On a terminal a
spinner runs while go build works. It ends with "Built bin/<binary> in <time>";
a compile error is printed and summer exits 1. Do not edit the generated
files: every build rewrites them.`,
Examples: []bonfire.Example{
{Command: "build", Description: "Regenerate main.go and build bin/<binary>"},
{Command: "build -q", Description: "Build and print only errors"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
if err != nil {
return err
}
return build.App(ctx, dir, out)
var log bytes.Buffer
run := func() error { return build.App(ctx, dir, &log) }
if bonfire.IsTerminal(out) {
err = out.Spinner("Building", run)
} else {
err = run()
}
for _, line := range strings.SplitAfter(log.String(), "\n") {
if line == "" {
continue
}
if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil {
out.Success("Built bin/" + strings.TrimSuffix(rest, "\n"))
continue
}
out.Printf("%s", line)
}
return err
},
}
}
@@ -68,6 +96,15 @@ func makePluginCommand() bonfire.Command {
return bonfire.Command{
Name: "make:plugin",
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
Long: `Creates plugins/<plugin> in the current application: a Go module with plugin.go
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
framework checkout when the application uses one. Register it with plugin:add
so the next summer build compiles it in. Run it inside an application; the
directory must not exist yet.`,
Examples: []bonfire.Example{
{Command: "make:plugin acme.blog", Description: "Create plugins/blog with the ID acme.blog"},
{Command: "plugin:add plugins/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
},
Args: []bonfire.Arg{{
Name: "id",
Description: "Plugin ID in vendor.plugin form",
@@ -86,7 +123,7 @@ func makePluginCommand() bonfire.Command {
if err != nil {
return err
}
out.Printf("created %s\n", created)
out.Success("Created " + created)
return nil
},
}
@@ -96,6 +133,17 @@ func makeModelCommand() bonfire.Command {
return bonfire.Command{
Name: "make:model",
Description: "Generate a GORM model and create-table migration",
Long: `Writes models/<name>.go with a GORM model and, unless --no-migration, a
create-table migration updates/<timestamp>_create_<table>.go, in the plugin
with this ID (or the plugin whose directory you are in, when the ID is left
out). The table is the plugin ID and the plural name in snake case, such as
acme_blog_posts. The plugin's registry.gen.go lists the new files, so the
plugin picks them up on the next build. Existing files and names are never
overwritten.`,
Examples: []bonfire.Example{
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
{Command: "make:model Post --no-migration", Description: "Inside plugins/blog: only the model"},
},
Args: makeArtifactArgs(),
Flags: []bonfire.Flag{{
Name: "no-migration",
@@ -124,6 +172,15 @@ func makeMigrationCommand() bonfire.Command {
return bonfire.Command{
Name: "make:migration",
Description: "Generate an ordered gormigrate stub",
Long: `Writes updates/<timestamp>_<name>.go with an empty gormigrate migration (up
and down steps to fill in) in the plugin with this ID or the plugin whose
directory you are in. The 14-digit timestamp keeps migrations in the order you
created them. registry.gen.go adds it to the plugin's migrations; run migrate
to apply it.`,
Examples: []bonfire.Example{
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
@@ -147,6 +204,16 @@ func makeCommandCommand() bonfire.Command {
return bonfire.Command{
Name: "make:command",
Description: "Generate a plugin console command",
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
plugin with this ID or the plugin whose directory you are in; the generated
registry adds it to the commands the plugin returns. The generated function
takes no arguments, so a command that needs the database or another service
takes a parameter instead and is returned from the plugin's Commands by hand
(see the porting guide's blog:publish).`,
Examples: []bonfire.Example{
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
{Command: "make:command Publish", Description: "The same from inside plugins/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
@@ -170,6 +237,14 @@ func makeJobCommand() bonfire.Command {
return bonfire.Command{
Name: "make:job",
Description: "Generate a plugin job without importing River",
Long: `Writes jobs/<name>.go with a queued job built with conga.Job (no River import)
in the plugin with this ID or the plugin whose directory you are in; the
generated registry adds it to the jobs the plugin returns. Dispatch it with
conga.Manager.Dispatch; queue:work or serve runs it.`,
Examples: []bonfire.Example{
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
{Command: "make:job SendDigest", Description: "The same from inside plugins/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
@@ -193,6 +268,16 @@ func makeAdminControllerCommand() bonfire.Command {
return bonfire.Command{
Name: "make:admin-controller",
Description: "Generate an admin controller with fields.yaml and columns.yaml",
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
starter fields.yaml and columns.yaml under models/<name>/, in the plugin with
this ID or the plugin whose directory you are in. The controller requires the
permission <plugin>.access_<name>, which you declare in the plugin's
Permissions(), and its NewRecord returns nil until you return your model, so
the screens stay closed until you finish it.`,
Examples: []bonfire.Example{
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
@@ -246,7 +331,7 @@ func flagTrue(in bonfire.Input, name string) bool {
func printArtifact(out bonfire.Output, result build.ArtifactResult) error {
for _, path := range result.Files {
out.Printf("created %s\n", path)
out.Success("Created " + path)
}
if result.Hint != "" {
out.Printf("%s\n", result.Hint)
@@ -287,6 +372,15 @@ func addPluginCommand() bonfire.Command {
return bonfire.Command{
Name: "plugin:add",
Description: "Register a local plugin module in the app manifest and workspace",
Long: `Adds a plugin that already exists on disk (a directory with plugin.go and
go.mod, for example one made by make:plugin or a shared plugin checked out as
a git submodule) to the application: its ID and module go into summer.yaml, a
require and a local replace into the application's go.mod, and the directory
into go.work. Run summer build afterwards. A plugin ID or module that is
already registered is an error.`,
Examples: []bonfire.Example{
{Command: "plugin:add plugins/acme/blog", Description: "Register the plugin in plugins/acme/blog"},
},
Args: []bonfire.Arg{{
Name: "dir",
Description: "Local plugin directory",
@@ -304,7 +398,7 @@ func addPluginCommand() bonfire.Command {
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil {
return err
}
out.Printf("added %s\n", pluginDir)
out.Success("Added " + pluginDir + " to summer.yaml, go.mod and go.work; run summer build")
return nil
},
}
@@ -314,6 +408,16 @@ func newCommand() bonfire.Command {
return bonfire.Command{
Name: "new",
Description: "Create a SummerCMS application in sm-<name>-app",
Long: `Creates sm-<name>-app in the current directory: summer.yaml, go.mod and go.work
(pointing at a local framework checkout found next to it, or --framework),
config/ including config/log.yaml, .env with a fresh app.key and
.env.example, compose.yaml for PostgreSQL, a starter plugin (unless
--no-plugin) and a git repository (unless --no-git). It prints the next steps:
start the database, summer build, summer migrate and summer serve.`,
Examples: []bonfire.Example{
{Command: "new acme", Description: "Create sm-acme-app with a starter plugin"},
{Command: "new acme --no-plugin --no-git", Description: "An empty application without a git repository"},
},
Args: []bonfire.Arg{{
Name: "name",
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",

View File

@@ -18,6 +18,16 @@ func parityProxyCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:proxy",
Description: "Record named HTTP sessions through a loopback reverse proxy",
Long: `Runs a recording reverse proxy on a loopback address in front of the reference
backend (--upstream). Point a real client (the frontend, an MCP server) at it
and use it: each named session, taken from the X-Parity-Session request header
or --session, is written as one fixture under --fixtures (nuxt/ or mcp/).
Captured secrets go to the --vars file (mode 0600), never into fixtures.
Existing fixtures are kept unless --update is true. Ctrl+C stops it.`,
Examples: []bonfire.Example{
{Command: "parity:proxy --upstream http://127.0.0.1:8000 --fixtures testdata/parity --vars /tmp/parity/vars.yaml", Description: "Record sessions against a reference backend on port 8000"},
{Command: "parity:proxy --session checkout --rules testdata/parity/rules.yaml --vars /tmp/parity/vars.yaml", Description: "Record everything as the checkout session with capture rules"},
},
Flags: []bonfire.Flag{
{Name: "listen", Description: "Loopback bind address", Default: tide.DefaultListen},
{Name: "upstream", Description: "Fixed loopback HTTP origin", Default: tide.DefaultUpstream},
@@ -35,6 +45,16 @@ func parityRecordCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:record",
Description: "Record HTTP responses for a one-flow YAML spec",
Long: `Sends the requests of a YAML spec (--spec) to the reference backend (--target)
and records the responses as a fixture (--output). With --manifest it instead
records the cases of a route manifest that have no fixture yet, at most
--next-batch (15) per run; --resume skips valid existing fixtures. Captured
secrets go to the --vars file. The true/false flags take the words true or
false.`,
Examples: []bonfire.Example{
{Command: "parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml", Description: "Record one flow"},
{Command: "parity:record --manifest testdata/parity/routes.yaml --fixtures testdata/parity --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --next-batch 10", Description: "Record the next ten unrecorded routes of a manifest"},
},
Flags: []bonfire.Flag{
{Name: "spec", Description: "YAML request spec path"},
{Name: "target", Description: "Base URL of the HTTP backend"},
@@ -57,6 +77,15 @@ func parityReplayCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:replay",
Description: "Replay recorded fixtures against an HTTP backend",
Long: `Replays recorded fixtures (a file or a directory, --fixtures) against a backend,
normally the port (--target), and reports each difference after masking IDs
and timestamps. --self-check fails on any mismatch; with --manifest and
--require-recorded a required case without a fixture fails too. It exits 1
when a fixture does not match.`,
Examples: []bonfire.Example{
{Command: "parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml", Description: "Replay every fixture against the port"},
{Command: "parity:replay --fixtures testdata/parity/posts.yaml --target http://127.0.0.1:8000 --self-check true", Description: "Check a fixture against the reference backend itself"},
},
Flags: []bonfire.Flag{
{Name: "fixtures", Description: "Recorded YAML fixture path or directory"},
{Name: "target", Description: "Base URL of the HTTP backend"},
@@ -73,6 +102,17 @@ func parityBroadcastsCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:broadcasts",
Description: "Record the Centrifugo publications a reference backend sends during a flow into a golden",
Long: `Starts a fake Centrifugo server on a loopback address (--listen), runs the flow
(--flow) against the reference backend (--target), which must be configured
to publish to it, and writes the publications it received into a golden file
(--out). --step records only one step, earlier steps running as setup; --ids
replaces id:* variables with placeholders; --settle waits for late
publications after each step; --pending marks the golden as recorded but not
yet asserted.`,
Examples: []bonfire.Example{
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/chat.broadcasts.yaml", Description: "Record the publications of a chat flow"},
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/send.broadcasts.yaml --step send --ids id:thread", Description: "Record one step with the thread id as a placeholder"},
},
Flags: []bonfire.Flag{
{Name: "flow", Description: "YAML flow spec to run against the target"},
{Name: "target", Description: "Loopback base URL of the reference backend"},
@@ -95,6 +135,16 @@ func parityUpstreamCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:upstream",
Description: "Record the vendor HTTPS calls a reference backend sends through a loopback recording proxy into an upstream sidecar",
Long: `Runs a loopback HTTPS recording proxy (--listen) with a locally generated CA
kept in --ca-dir. Configure the reference backend to send its vendor API calls
(payments, mail, maps) through it and to trust the CA. In script mode each
call is answered from --script; in forward mode it is sent once to the real
vendor. When interrupted with Ctrl+C it writes the calls, credentials masked
with the --vars values, into the upstream sidecar --out.`,
Examples: []bonfire.Example{
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --script testdata/parity/pay.script.yaml --vars /tmp/parity/vars.yaml", Description: "Answer vendor calls from a script"},
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --mode forward --vars /tmp/parity/vars.yaml", Description: "Forward each call once to the real vendor"},
},
Flags: []bonfire.Flag{
{Name: "listen", Description: "Loopback address of the recording proxy", Default: tide.DefaultUpstreamProxyListen},
{Name: "ca-dir", Description: "Directory of the local parity CA, outside the fixtures tree (key kept mode 0600)"},

View File

@@ -21,29 +21,65 @@ import (
var appRunner = runAppBinary
// delegateCommands returns every command summer runs on the app binary. One
// table is the single source: name, base description and a hint naming the
// common flags or arguments the app binary accepts.
// table is the single source: name, base description, a hint naming the
// common flags or arguments the app binary accepts, and two examples.
func delegateCommands() []bonfire.Command {
rows := []struct{ name, description, hint string }{
{"migrate", "Run plugin migrations in the app binary", ""},
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
{"route:list", "List the app's routes from the app binary", ""},
{"key:generate", "Print a fresh application key from the app binary", ""},
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD]"},
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked]"},
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>"},
{"admin:token:scopes", "List available personal API token scopes in the app binary", ""},
ex := func(cmd, desc string) bonfire.Example { return bonfire.Example{Command: cmd, Description: desc} }
rows := []struct {
name, description, hint string
examples []bonfire.Example
}{
{"migrate", "Run plugin migrations in the app binary", "--pretend, --force", []bonfire.Example{
ex("migrate", "Apply every pending migration"),
ex("migrate --pretend", "List the pending migrations without applying them")}},
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>, --pretend, --force", []bonfire.Example{
ex("migrate:rollback --plugin acme.blog", "Roll back the last migration of acme.blog"),
ex("migrate:rollback --plugin acme.blog --pretend", "Name the migration that would roll back")}},
{"migrate:status", "Show per-plugin migration history in the app binary", "--json", []bonfire.Example{
ex("migrate:status", "Show the applied migrations per plugin"),
ex("migrate:status --json", "Machine-readable status")}},
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)", []bonfire.Example{
ex("serve", "Serve on port 8080"),
ex("serve --addr 127.0.0.1:8123", "Serve on loopback port 8123")}},
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)", []bonfire.Example{
ex("queue:work", "Work every known queue"),
ex("queue:work --queue mail", "Work only the mail queue")}},
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default"), --force`, []bonfire.Example{
ex("queue:clear", "Clear the default queue"),
ex("queue:clear imports --force", "Clear the imports queue without the production question")}},
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once", []bonfire.Example{
ex("schedule:run", "Run the scheduler in the foreground"),
ex("schedule:run --once", "Run the entries due now and exit")}},
{"route:list", "List the app's routes from the app binary", "--json", []bonfire.Example{
ex("route:list", "Show every route"),
ex("route:list --json", "Machine-readable route list")}},
{"key:generate", "Print a fresh application key from the app binary", "--json", []bonfire.Example{
ex("key:generate", "Print a new key"),
ex("key:generate -q", "Print only the key")}},
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin", []bonfire.Example{
ex("admin:create --email admin@example.com --superuser", "Create a superuser; the password is asked for"),
ex("admin:create --email editor@example.com --role editor", "Create an administrator with a role")}},
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin", []bonfire.Example{
ex("admin:reset-password admin@example.com", "Reset a password; it is asked for"),
ex("admin:reset-password admin", "Reset by login")}},
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD] [--json]", []bonfire.Example{
ex("admin:token:create jane --name agent --scope journal.posts", "A token limited to one scope"),
ex("admin:token:create jane --scope journal.posts --json", "The token and its details as JSON")}},
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked] [--json]", []bonfire.Example{
ex("admin:token:list", "Every active token"),
ex("admin:token:list jane --revoked", "Every token of one administrator")}},
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>", []bonfire.Example{
ex("admin:token:revoke 12", "Revoke token 12"),
ex("admin:token:revoke 7 -q", "Revoke token 7 and print nothing but errors")}},
{"admin:token:scopes", "List available personal API token scopes in the app binary", "--json", []bonfire.Example{
ex("admin:token:scopes", "Show every scope"),
ex("admin:token:scopes --json", "Machine-readable scope list")}},
}
cmds := make([]bonfire.Command, 0, len(rows))
for _, r := range rows {
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
c := delegateCommand(r.name, r.description, r.hint)
c.Examples = r.examples
cmds = append(cmds, c)
}
return cmds
}
@@ -53,7 +89,8 @@ func delegateCommands() []bonfire.Command {
func delegateCommand(name, description, hint string) bonfire.Command {
long := description + ".\n\nsummer runs bin/<binary> " + name +
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
name + ", including --help, is passed to the app binary unchanged."
name + ", including --help, is passed to the app binary unchanged, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " +
"summer fails when the app binary fails."
if hint != "" {
long += "\n\nCommon flags: " + hint + "."
}

View File

@@ -134,6 +134,13 @@ summer admin:create --email admin@example.com --superuser
One leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same as `summer serve --addr 127.0.0.1:8123`.
The global options go to the binary too, wherever you put them: `summer -v migrate --pretend` runs `./bin/acme -v migrate --pretend`, and `summer -q key:generate` prints only the key. `summer help migrate` shows each delegate's examples; the binary's own help, with every flag, is `summer migrate --help`:
```sh
summer -v migrate --pretend
summer admin:token:create jane --scope journal.posts --json
```
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly.

View File

@@ -12,7 +12,7 @@ The `summer` tool creates and builds applications and generates the files a plug
| Command | Purpose |
|---------|---------|
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. Run it from the application directory or any directory below it. |
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. On a terminal a spinner runs while `go build` works; it ends with `Built bin/<binary> in <time>`. Run it from the application directory or any directory below it. |
| `summer dev` | Builds the application as `summer build` does (build tags included), runs `bin/<binary> serve`, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. A failed build keeps the running server. |
```sh
@@ -119,4 +119,17 @@ File names are the snake-case form of the name: `AddPublishedAt` becomes `add_pu
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
A command refuses to overwrite an existing file or to declare a name the package already has.
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.
When a `make:` command cannot do its work, the error says why and what to run instead. Outside an application (no `summer.yaml` in the directory or above it) it reads:
```text
ERROR build: no summer.yaml found from /home/jane
summer make:model runs inside an application: a directory with summer.yaml, or one below it.
Try:
cd <app>; summer make:model acme.blog Post
```
An invalid plugin ID (`Acme.Blog`), a name that is not an exported Go identifier (`post`), an artifact that already exists and a plugin that `summer.yaml` does not list get the same kind of explanation, with a valid command form to try.

View File

@@ -101,7 +101,7 @@ SummerCMS acme v1.4.0 (framework v0.4.0)
The database line shows the host, port and database name, never the user or password. Pending migrations read `3 pending — run bin/acme migrate`. The admin line shows the admin URL, or `off` with the steps to add an admin controller; a warning naming `summer admin:create` follows when the `backend_users` table is empty. `-v` adds one line per plugin with the plugins it requires and its pending migrations, and lets every component's info logs through; `-q` prints no summary, only warnings and errors.
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the two records still reach the daily files, but the console does not repeat them.
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the console does not repeat the two records after the block; a file channel whose level lets Info through still gets them (the default daily channel keeps only warnings and errors).
### Shutting down

View File

@@ -20,7 +20,7 @@ When you port an existing backend to SummerCMS, its real responses are the contr
| `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. |
| `summer parity:upstream` | `--listen` (default `127.0.0.1:8425`), `--ca-dir`, `--out`, `--mode` (default `script`; or `forward`), `--script`, `--vars` | Runs a loopback HTTPS recording proxy with a locally generated CA. The reference backend sends its vendor API calls through it; each call is answered from the `--script` file (or forwarded once to the vendor in `forward` mode) and written, with credentials masked, into an upstream sidecar (`<fixture>.upstream.yaml`) when the command is interrupted. |
A typical port records once against the reference backend and replays against the Go backend on every change:
Each command's help (`summer help parity:record`) explains its place in this workflow and shows examples. A typical port records once against the reference backend and replays against the Go backend on every change:
```sh
summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml
@@ -45,6 +45,6 @@ summer docs:sync
summer docs:serve
```
`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
`summer help docs:build` and `summer help docs:sync` show examples. `docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
`docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk.

View File

@@ -138,6 +138,18 @@ bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
`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.
### What a finished command has
The framework's own commands follow these rules, and so should a plugin's:
- **Description:** one line in the imperative, sentence case, without a trailing period: "Publish a blog post by its slug".
- **Long help:** what the command does, when to run it (by hand, from cron, from the scheduler), what each flag changes, and its exit codes.
- **Two or three examples:** the common call first, then the flags people reach for.
- **A result line:** a one-shot command ends with a line that says what it did, such as `Published hello-world` or `Imported 120 posts (3 skipped)`, and returns an error when it failed, so the exit code is 1.
- **Data on stdout:** what a script reads (a token, a key, `--json` output) goes through `Println` or `Printf`, which `-q` does not hide; information and success lines go through `Info` and `Success`, which it does.
- **Flags with a meaning people know:** `--json` for output scripts read, `--pretend` or `--dry-run` for a run that changes nothing, `--force` to skip a confirmation. A destructive step asks through `bonfire.ConfirmProceed`, which never asks off an interactive terminal; never give an existing flag a new meaning.
- **A named logger:** log through `diary.For("<plugin code>")` from a package variable, not `slog.Default`, so `log.components` can raise or silence the plugin. See [Logging](../services/logging.md).
## 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.

View File

@@ -1,6 +1,6 @@
---
title: Upgrading
description: Move an application to a newer framework release, what the version numbers promise, and the config to check before you take 0.2.
description: Move an application to a newer framework release, what the version numbers promise, and what to check before you take 0.2 and 0.4.
section: setup
order: 60
---
@@ -24,6 +24,28 @@ SummerCMS is in the 0.x series, and the version tells you whether to read before
Applications pin exact versions, so a new release never reaches an application until it moves its `go.mod` or its checkout.
## Upgrading to 0.4
0.4 rebuilds the console: Artisan-style `list` and help with examples, global options on every command, a logging pipeline with daily files, startup summaries and shutdown reports. Plugins compile unchanged. After you move the framework version, run `summer build` to regenerate `main.go`: the new generated main sets up logging before the configuration is read, parses the global options and returns the exit codes below. Then run `bin/<app> list` once with the new binary: it boots every plugin and runs the checks below.
### Boot refusals
**Duplicate command names.** Two commands with the same name, from two plugins or from a plugin and the framework, stop the binary at start-up with `bonfire.ErrDuplicateCommand` naming the command. Remove or rename one of them; a plugin that appends `centrifugo.Commands` or `flare.Commands` must be the only one that does.
**Reserved flag names.** A command flag named like a global option (`verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env`, or the shorthands `v`, `q`, `n`) stops the binary with `bonfire.ErrReservedFlag`. Rename the flag.
### Behaviour changes
- **Daily log files.** Logs go to the console and, from warnings up, to `storage/logs/summer-YYYY-MM-DD.log`, kept for 14 days. The directory must be writable by the user that runs the binary; when it is not, the binary logs one warning and keeps logging to the console. To keep the old console-only behaviour, set `log.default` to `console` (`SUMMER_LOG__DEFAULT=console`). See [Logging](../services/logging.md).
- **Component loggers.** Every log record carries a `component` attribute. GORM's lines move to component `db` and record-not-found is no longer logged; River's lines to component `river` (warnings and up by default). Raise or lower a component with `log.components`, for example `components: {db: debug}`.
- **The request log is on by default.** Every request writes one Info record `http request` from component `http` with the route pattern (never the path), status, duration and `request_id`. Silence it with `components: {http: warn}` in `config/log.yaml`, or turn it off with `http.access_log: false`.
- **`serve` prints a startup summary.** On a terminal it is a block; off a terminal `serve` prints nothing on stdout and logs a `startup summary` record and a `listening on <addr>` record on stderr instead, so a script that waits for `listening on 127.0.0.1:<port>` in the combined output still finds it. Ctrl+C prints a shutdown report and exits 0; a failed drain exits 1. `queue:work` and `schedule:run` do the same.
- **Exit codes.** A failed command exits 1 and a usage error (an unknown command or flag, a wrong number of arguments) exits 2. Errors print as a block on stderr with a hint where the framework knows the fix, and `-v` shows the wrapped errors.
- **`-q` keeps data.** `-q` hides information and success lines, spinners and progress bars, but still prints what a command exists to produce: the token of `admin:token:create`, the key of `key:generate` and every `--json` output.
- **Production confirmation.** `migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` ask before they run when the environment is `production` and they run on an interactive terminal; `--force` skips the question. Off a terminal, as in deploy scripts over SSH, nothing changes.
- **`summer dev` runs `serve`.** It used to start the binary without a command, which printed the help and stopped; it now runs `bin/<app> serve` with the global options you give `summer dev`.
- **Result lines.** `summer build` ends with `Built bin/<app> in <time>`, the `make:` commands print `Created <path>` per file, `migrate` prints `Applied N migrations` or `Nothing to migrate`, and `queue:clear` prints `Cleared N jobs from <queue>`. Adjust scripts that matched the old lower-case lines.
## Upgrading to 0.2
0.2 tightens two configuration checks so that settings which leaked credentials stop the start-up instead. Most applications are not affected: the first concerns applications that load an `auth.cookie` block, the second applications that send credentialed CORS answers. Start the new binary once in each environment before you switch traffic to it; a refused configuration fails `serve` at start-up with an error that names the key.

View File

@@ -10,6 +10,8 @@ import (
"time"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// ArtifactResult is the outcome of a make:* command.
@@ -46,7 +48,7 @@ func MakeModel(ctx context.Context, startDir, pluginID, name string, noMigration
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: model: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: model: %w", err))
}
table, err := modelTableName(plugin.ID, name)
if err != nil {
@@ -125,7 +127,7 @@ func MakeMigration(ctx context.Context, startDir, pluginID, name string) (Artifa
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: migration: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: migration: %w", err))
}
if err := rejectDuplicateIdent(plugin.Dir, "updates", name); err != nil {
return ArtifactResult{}, err
@@ -163,7 +165,7 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: command: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: command: %w", err))
}
fn := name + "Command"
if err := rejectDuplicateIdent(plugin.Dir, "console", fn); err != nil {
@@ -201,7 +203,7 @@ func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResu
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: job: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: job: %w", err))
}
fn := name + "Job"
if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil {
@@ -237,7 +239,7 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: admin-controller: %w", err)
return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err))
}
fn := name + "Controller"
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
@@ -325,6 +327,35 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil
}
// pluginIDHint explains an invalid plugin ID.
func pluginIDHint(err error) error {
return bonfire.WithHint(err,
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
"{bin} "+cliCommand("make:model")+" acme.blog <Name>")
}
// identHint explains an invalid artifact name.
func identHint(err error) error {
return bonfire.WithHint(err,
"The name becomes an exported Go identifier: a capital letter, then letters and digits.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> BlogPost")
}
// existsHint explains an artifact that is already there.
func existsHint(err error) error {
return bonfire.WithHint(err,
"summer make: never overwrites a file or a name the plugin already has; choose another name, or edit the existing one.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <OtherName>")
}
// pluginMissingHint explains a plugin that summer.yaml does not list and
// plugins/ does not hold.
func pluginMissingHint(err error, id string) error {
return bonfire.WithHint(err,
"The plugin is not listed in summer.yaml and has no directory under plugins/.",
"{bin} make:plugin "+id+"\n{bin} plugin:add <dir>")
}
func unexportedIdent(name string) string {
r, size := utf8.DecodeRuneInString(name)
return string(unicode.ToLower(r)) + name[size:]
@@ -348,7 +379,7 @@ func resolvePlugin(startDir, id string) (pluginRef, error) {
}
}
if err := ValidatePluginID(id); err != nil {
return pluginRef{}, fmt.Errorf("build: %w", err)
return pluginRef{}, pluginIDHint(fmt.Errorf("build: %w", err))
}
pluginDir, err := locatePluginDir(appDir, id)
if err != nil {
@@ -400,7 +431,9 @@ func inferPluginID(start, appDir string) (string, error) {
}
dir = parent
}
return "", fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start)
return "", bonfire.WithHint(fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start),
"Without a plugin ID, summer "+cliCommand("make:model")+" looks for plugin.go in this directory and above it.",
"{bin} "+cliCommand("make:model")+" <vendor.plugin> <Name>")
}
func locatePluginDir(appDir, id string) (string, error) {
@@ -425,12 +458,12 @@ func locatePluginDir(appDir, id string) (string, error) {
st, err := os.Stat(candidate)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("build: plugin %s not found", id)
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return "", fmt.Errorf("build: stat %s: %w", candidate, err)
}
if !st.IsDir() {
return "", fmt.Errorf("build: plugin %s not found", id)
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return candidate, nil
}
@@ -492,7 +525,7 @@ func rejectDuplicateFile(pluginDir, rel string) error {
return fmt.Errorf("build: artifact path %q escapes the plugin directory", rel)
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("build: %s already exists", rel)
return existsHint(fmt.Errorf("build: %s already exists", rel))
} else if !os.IsNotExist(err) {
return fmt.Errorf("build: stat %s: %w", path, err)
}
@@ -506,7 +539,7 @@ func rejectDuplicateIdent(pluginDir, pkg, ident string) error {
}
for _, ref := range refs {
if ref.Ident == ident {
return fmt.Errorf("build: %s.%s already exists", pkg, ident)
return existsHint(fmt.Errorf("build: %s.%s already exists", pkg, ident))
}
}
return nil

View File

@@ -8,6 +8,7 @@ import (
"strings"
"unicode"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/knadh/koanf/parsers/yaml"
)
@@ -193,6 +194,34 @@ func ValidatePluginID(id string) error {
return nil
}
// cliInvocation is the summer command line being run (os.Args without the
// program), shell-quoted, for the Try line of a hint.
func cliInvocation() string {
parts := make([]string, 0, len(os.Args))
for _, a := range os.Args[1:] {
if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
a = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
}
parts = append(parts, a)
}
return strings.Join(parts, " ")
}
// cliCommand is the summer command being run (the first argument that is
// not an option), or def when there is none, for hint texts.
func cliCommand(def string) string {
args := os.Args[1:]
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "--env":
i++
case a != "" && !strings.HasPrefix(a, "-"):
return a
}
}
return def
}
// FindAppDir walks up from start looking for summer.yaml.
func FindAppDir(start string) (string, error) {
return findAppDir(start)
@@ -212,7 +241,9 @@ func findAppDir(start string) (string, error) {
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("build: no %s found from %s", manifestName, start)
return "", bonfire.WithHint(fmt.Errorf("build: no %s found from %s", manifestName, start),
"summer "+cliCommand("build")+" runs inside an application: a directory with summer.yaml, or one below it.",
"cd <app>; {bin} "+cliInvocation())
}
dir = parent
}

View File

@@ -15,6 +15,8 @@ import (
"strconv"
"strings"
"text/template"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
//go:embed stubs/*.tmpl
@@ -63,7 +65,9 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (string, error) {
if err := ValidatePluginID(id); err != nil {
return "", fmt.Errorf("build: %w", err)
return "", bonfire.WithHint(fmt.Errorf("build: %w", err),
"A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.",
"{bin} make:plugin acme.blog")
}
appDir, err := findAppDir(startDir)
if err != nil {
@@ -75,7 +79,9 @@ func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (s
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
}
if _, err := os.Stat(pluginDir); err == nil {
return "", fmt.Errorf("build: plugin directory %s already exists", pluginDir)
return "", bonfire.WithHint(fmt.Errorf("build: plugin directory %s already exists", pluginDir),
"summer make:plugin creates a new directory under plugins/ and never overwrites one; register an existing plugin with plugin:add.",
"{bin} plugin:add "+filepath.Join("plugins", name))
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
}