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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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 + "."
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user