diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go index 1cb0dd88..de0223d0 100644 --- a/cmd/summer/docs.go +++ b/cmd/summer/docs.go @@ -136,13 +136,15 @@ func docsOptions(in bonfire.Input) docsite.Options { } // docsCommands collects the command names docs pages may show: the summer -// tool's own commands, and every command the generated application main +// tool's own commands, list and help (added by bonfire.NewRoot), and every +// command the generated application main // registers (TestDocsCommandsMirrorGeneratedMain keeps this list in step // with internal/build) plus the realtime and push commands applications // append. The constructors only capture the app, so an empty config is // enough; nothing runs. func docsCommands() *docsite.Commands { - var tool []string + // list and help come from bonfire.NewRoot, in both programs. + tool := []string{"list", "help"} for _, c := range toolCommands() { tool = append(tool, c.Name) } @@ -156,7 +158,7 @@ func docsCommands() *docsite.Commands { appCmds = append(appCmds, cabana.RuntimeCommands(app)...) appCmds = append(appCmds, centrifugo.Commands(app)...) appCmds = append(appCmds, flare.Commands(app)...) - names := make([]string, 0, len(appCmds)) + names := []string{"list", "help"} for _, c := range appCmds { names = append(names, c.Name) } diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index 5225c530..78b12932 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -401,12 +401,12 @@ func first(lines []string) string { func TestDocsCommandNames(t *testing.T) { cmds := docsCommands() - for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} { + for _, want := range []string{"docs:build", "make:plugin", "migrate:status", "list", "help"} { if !slices.Contains(cmds.Tool, want) { t.Errorf("Tool is missing %s: %v", want, cmds.Tool) } } - for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} { + for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health", "list", "help"} { if !slices.Contains(cmds.App, want) { t.Errorf("App is missing %s: %v", want, cmds.App) } diff --git a/cmd/summer/main.go b/cmd/summer/main.go index f0b8c707..8a9556fc 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -9,20 +9,20 @@ import ( "git.golem15.com/golem15/summercms/internal/build" "git.golem15.com/golem15/summercms/internal/dev" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/lagoon" ) func main() { - root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout) + args := os.Args[1:] + g := bonfire.PeekGlobals(args) + diary.Bootstrap(os.Stderr, diary.FromGlobals(g)) + root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout, + bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals)) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - root.SetArgs(os.Args[1:]) - if err := root.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + os.Exit(bonfire.Fail(os.Stderr, "summer", err, g)) } + os.Exit(bonfire.Execute(context.Background(), root, args)) } func toolCommands() []bonfire.Command { diff --git a/docs/console/introduction.md b/docs/console/introduction.md index 86563cab..150c9760 100644 --- a/docs/console/introduction.md +++ b/docs/console/introduction.md @@ -15,49 +15,52 @@ WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`. -Run with no arguments, the binary prints its usage and every command it has, the framework's and those its plugins add; `--help` prints the same: +Run with no arguments, the binary prints the list of every command it has, the framework's and those its plugins add, the way `php artisan` does; `--help` and `list` print the same: ```sh ./bin/acme ./bin/acme --help +./bin/acme list ``` ```text +SummerCMS acme v1.4.0 (framework v0.3.2) + Usage: - acme [command] + bin/acme [options] [arguments] -Available Commands: - admin:create Create an activated backend administrator - admin:reset-password Reset a backend administrator password and revoke existing tokens - admin:token:create Create a personal API token for a backend administrator - admin:token:list List personal API tokens for a backend administrator (or all) - admin:token:revoke Revoke a personal API token by id - admin:token:scopes List available personal API token scopes - deferred:purge Delete expired deferred bindings with their pending uploads and created records - greeter:hello Print the configured application name - help Help about any command - key:generate Print a fresh 32-byte base64 app.key - migrate Run plugin migrations in dependency order - migrate:rollback Roll back the last migration of a plugin - migrate:status Show per-plugin migration history - queue:clear Clear all queued jobs, by deleting all pending jobs. - queue:work Run background job workers in the foreground - route:list List registered HTTP routes - schedule:run Run the scheduler in the foreground (or once for system cron) - serve Serve the application HTTP API +Options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity + --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question + --env The environment the command runs under -Flags: - -h, --help help for acme - -Use "acme [command] --help" for more information about a command. +Available commands: + help Display help for a command + list List commands + migrate Run plugin migrations in dependency order + serve Serve the application HTTP API + admin + admin:create Create an activated backend administrator + admin:reset-password Reset a backend administrator password and revoke existing tokens + admin:token:create Create a personal API token for a backend administrator + migrate + migrate:rollback Roll back the last migration of a plugin + migrate:status Show per-plugin migration history + queue + queue:work Run background job workers in the foreground ``` +The header names the application, its version and the framework version it was built against (`local` when the framework comes from a local checkout). Commands without a namespace come first, then one block per namespace, the part of the name before the first colon. `summer` with no arguments prints its own list in the same layout. + ## Getting help -Both programs list their commands with `--help`, and every command accepts `--help` for its arguments and flags: +Both programs list their commands with no arguments, with `--help` and with `list`, and every command accepts `--help` for its arguments and flags: ```sh -summer --help +summer summer make:model --help ./bin/acme --help ./bin/acme migrate:rollback --help @@ -65,6 +68,33 @@ summer make:model --help A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary. +### Global options + +Every command of both programs accepts these options, before or after the command name: + +| Option | Effect | +|--------|--------| +| `-v`, `-vv`, `-vvv` (`--verbose`) | More output: `-v` shows info logs of every component, `-vv` debug logs, `-vvv` also source locations and SQL traces. | +| `-q`, `--quiet` | Shows only warnings and errors in the console log. Implies `-n`. | +| `--ansi`, `--no-ansi` | Forces or turns off colors and styling; the last one given wins. Without either, colors follow the terminal, `NO_COLOR` and `FORCE_COLOR`. | +| `-n`, `--no-interaction` | Marks the run as not interactive. | +| `--env` | The configuration environment, as `SUMMER_ENV` would set it: `--env staging` or `--env=staging`. | + +The application binary reads these options before it loads `config/` and starts plugins, so `--env` picks the configuration and `-vv` applies from the first log line: + +```sh +./bin/acme -vv migrate +./bin/acme migrate:status --env staging +``` + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The command succeeded. | +| `1` | The command failed; the error is printed on stderr. | +| `2` | The command was called wrongly: an unknown command or flag, or a wrong number of arguments. | + ## Commands that summer delegates During development you often work from the application directory with `summer` alone. These `summer` commands find the application's `summer.yaml`, build `bin/` when it does not exist yet, and run the same command of the binary with the same arguments: diff --git a/docs/console/writing-commands.md b/docs/console/writing-commands.md index f7ab696f..28cfb29e 100644 --- a/docs/console/writing-commands.md +++ b/docs/console/writing-commands.md @@ -48,7 +48,7 @@ if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); e ## Registering commands -Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names are not checked for duplicates, so keep your commands in your plugin's own namespace, such as `blog:`. +Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named `list` or `help`, `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as `blog:`. The global options are reserved as well: a flag named `verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env` or `help`, or with the shorthand `v`, `q`, `n` or `h`, makes `bonfire.NewRoot` fail with `bonfire.ErrReservedFlag`. A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`. diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 105d1874..2e606dc0 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "os/exec" + "slices" "strings" "testing" @@ -48,8 +49,8 @@ func runHello(t *testing.T, env map[string]string) string { t.Setenv(k, v) } var buf bytes.Buffer - if err := run([]string{"greeter:hello"}, &buf); err != nil { - t.Fatalf("greeter:hello: %v", err) + if code := run([]string{"greeter:hello"}, &buf); code != 0 { + t.Fatalf("greeter:hello: exit %d\n%s", code, buf.String()) } return strings.TrimSpace(buf.String()) } @@ -231,11 +232,11 @@ func TestTypedItemRoute(t *testing.T) { func TestHelpListsServe(t *testing.T) { var buf bytes.Buffer - if err := run([]string{"--help"}, &buf); err != nil { - t.Fatal(err) + if code := run([]string{"--help"}, &buf); code != 0 { + t.Fatalf("--help: exit %d\n%s", code, buf.String()) } help := buf.String() - for _, name := range []string{"serve", "migrate", "migrate:status", "migrate:rollback"} { + for _, name := range []string{"Available commands:", "serve", "migrate", "migrate:status", "migrate:rollback"} { if !strings.Contains(help, name) { t.Fatalf("help missing %s:\n%s", name, help) } @@ -244,9 +245,19 @@ func TestHelpListsServe(t *testing.T) { func TestMigrateWithoutDSNFailsLoudly(t *testing.T) { var buf bytes.Buffer - err := run([]string{"migrate"}, &buf) - if err == nil || !strings.Contains(err.Error(), "database.dsn") { - t.Fatalf("want empty DSN error, got %v\n%s", err, buf.String()) + if code := run([]string{"migrate"}, &buf); code != 1 { + t.Fatalf("migrate without a DSN: exit %d, want 1\n%s", code, buf.String()) + } + runSummerBuild(t) + cmd := exec.Command("./bin/hello", "migrate") + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + t.Fatalf("bin/hello migrate: %v, want exit 1\n%s", err, stderr.String()) + } + if !strings.Contains(stderr.String(), "database.dsn") { + t.Fatalf("stderr does not name database.dsn:\n%s", stderr.String()) } } @@ -254,8 +265,34 @@ func TestBuiltBinaryUnknownCommandExitsNonZero(t *testing.T) { runSummerBuild(t) cmd := exec.Command("./bin/hello", "does:not-exist") out, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected non-zero exit, output:\n%s", out) + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 { + t.Fatalf("unknown command: %v, want exit 2, output:\n%s", err, out) + } +} + +// TestBuiltBinaryListsCommands: the bare binary prints the Artisan list with +// the version header and the plugin namespace, and global options are +// accepted before a command. +func TestBuiltBinaryListsCommands(t *testing.T) { + runSummerBuild(t) + cmd := exec.Command("./bin/hello") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("bin/hello: %v\n%s%s", err, stdout.String(), stderr.String()) + } + got := stdout.String() + for _, want := range []string{"SummerCMS hello", "Available commands:", "greeter:hello"} { + if !strings.Contains(got, want) { + t.Fatalf("list missing %q:\n%s", want, got) + } + } + if !slices.Contains(strings.Split(got, "\n"), " greeter") { + t.Fatalf("list has no greeter namespace line:\n%s", got) + } + verbose := exec.Command("./bin/hello", "-vv", "list") + if out, err := verbose.CombinedOutput(); err != nil { + t.Fatalf("bin/hello -vv list: %v\n%s", err, out) } } diff --git a/examples/hello/main.go b/examples/hello/main.go index dcf5f402..ee78a1be 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -3,7 +3,7 @@ package main import ( - "fmt" + "context" "io" "os" @@ -20,25 +20,23 @@ import ( ) func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + os.Exit(run(os.Args[1:], os.Stdout)) } -func run(args []string, out io.Writer) error { - diary.Bootstrap(os.Stderr, diary.Options{}) - cfg, err := compass.Load("config") +func run(args []string, out io.Writer) int { + g := bonfire.PeekGlobals(args) + diary.Bootstrap(os.Stderr, diary.FromGlobals(g)) + cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env}) if err != nil { - return err + return bonfire.Fail(os.Stderr, "hello", err, g) } - if err := diary.Setup(cfg, diary.Options{}); err != nil { - return err + if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil { + return bonfire.Fail(os.Stderr, "hello", err, g) } app := backpack.New(cfg) plugins, err := party.Activate(app, PluginIDs) if err != nil { - return err + return bonfire.Fail(os.Stderr, "hello", err, g) } commands := lagoon.RuntimeCommands(app, plugins) commands = append(commands, conga.RuntimeCommands(app, plugins)...) @@ -51,12 +49,11 @@ func run(args []string, out io.Writer) error { } } if err := app.Publish(bonfire.NewCatalog(commands)); err != nil { - return err + return bonfire.Fail(os.Stderr, "hello", err, g) } - root, err := bonfire.NewRoot("hello", commands, out) + root, err := bonfire.NewRoot("hello", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals)) if err != nil { - return err + return bonfire.Fail(os.Stderr, "hello", err, g) } - root.SetArgs(args) - return root.Execute() + return bonfire.Execute(context.Background(), root, args) } diff --git a/go.mod b/go.mod index 1f317746..3e838c6e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.27.0 ignore ./admin/node_modules require ( + charm.land/fang/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/alecthomas/chroma/v2 v2.27.0 github.com/alicebob/miniredis/v2 v2.40.0 @@ -30,6 +31,7 @@ require ( github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 github.com/riverqueue/river/rivertype v0.47.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/wneessen/go-mail v0.8.1 @@ -72,6 +74,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -125,6 +128,10 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect @@ -134,7 +141,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/stretchr/testify v1.12.1 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.2.0 // indirect diff --git a/go.sum b/go.sum index 14ba9efd..43c36169 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -78,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3 github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -94,6 +98,10 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVW github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -269,6 +277,14 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/internal/build/build.go b/internal/build/build.go index 70ddd911..935bcbf8 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -96,7 +96,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") b.WriteString("import (\n") - b.WriteString("\t\"fmt\"\n") + b.WriteString("\t\"context\"\n") b.WriteString("\t\"io\"\n") b.WriteString("\t\"os\"\n\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n") @@ -111,25 +111,19 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n") b.WriteString(")\n\n") b.WriteString("func main() {\n") - b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n") - b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n") - b.WriteString("\t\tos.Exit(1)\n") - b.WriteString("\t}\n") + b.WriteString("\tos.Exit(run(os.Args[1:], os.Stdout))\n") b.WriteString("}\n\n") - b.WriteString("func run(args []string, out io.Writer) error {\n") - b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.Options{})\n") - b.WriteString("\tcfg, err := compass.Load(\"config\")\n") - b.WriteString("\tif err != nil {\n") - b.WriteString("\t\treturn err\n") - b.WriteString("\t}\n") - b.WriteString("\tif err := diary.Setup(cfg, diary.Options{}); err != nil {\n") - b.WriteString("\t\treturn err\n") - b.WriteString("\t}\n") + binary := strconv.Quote(m.Binary) + fail := "\t\treturn bonfire.Fail(os.Stderr, " + binary + ", err, g)\n" + b.WriteString("func run(args []string, out io.Writer) int {\n") + b.WriteString("\tg := bonfire.PeekGlobals(args)\n") + b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.FromGlobals(g))\n") + b.WriteString("\tcfg, err := compass.Open(compass.Options{Dir: \"config\", Env: g.Env})\n") + b.WriteString("\tif err != nil {\n" + fail + "\t}\n") + b.WriteString("\tif err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {\n" + fail + "\t}\n") b.WriteString("\tapp := backpack.New(cfg)\n") b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n") - b.WriteString("\tif err != nil {\n") - b.WriteString("\t\treturn err\n") - b.WriteString("\t}\n") + b.WriteString("\tif err != nil {\n" + fail + "\t}\n") b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") b.WriteString("\tcommands = append(commands, conga.RuntimeCommands(app, plugins)...)\n") b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n") @@ -140,15 +134,10 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") b.WriteString("\t\t}\n") b.WriteString("\t}\n") - b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n") - b.WriteString("\t\treturn err\n") - b.WriteString("\t}\n") - fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary)) - b.WriteString("\tif err != nil {\n") - b.WriteString("\t\treturn err\n") - b.WriteString("\t}\n") - b.WriteString("\troot.SetArgs(args)\n") - b.WriteString("\treturn root.Execute()\n") + b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n" + fail + "\t}\n") + fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))\n", binary) + b.WriteString("\tif err != nil {\n" + fail + "\t}\n") + b.WriteString("\treturn bonfire.Execute(context.Background(), root, args)\n") b.WriteString("}\n") return formatSource([]byte(b.String())) } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 33563703..4dcce571 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -142,22 +142,26 @@ func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) { } } -// TestGenerateMainInstallsDiary: the first statement of run installs the -// diary console logger, before config loads or any plugin can log, and +// TestGenerateMainInstallsDiary: run first reads the console global options +// (bonfire.PeekGlobals), then installs the diary console logger with them, +// before config loads (in the --env environment) or any plugin can log; // diary.Setup applies config/log.yaml right after compass loads it and before -// the application container exists. +// the application container exists, and the root re-applies the parsed +// options to diary through bonfire.OnGlobals. func TestGenerateMainInstallsDiary(t *testing.T) { mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) if err != nil { t.Fatal(err) } - bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.Options{})") - setup := []byte("diary.Setup(cfg, diary.Options{})") - if n := bytes.Count(mainSrc, bootstrap); n != 1 { - t.Fatalf("Bootstrap count = %d, want 1\n%s", n, mainSrc) - } - if n := bytes.Count(mainSrc, setup); n != 1 { - t.Fatalf("Setup count = %d, want 1\n%s", n, mainSrc) + peek := []byte("g := bonfire.PeekGlobals(args)") + bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.FromGlobals(g))") + open := []byte(`compass.Open(compass.Options{Dir: "config", Env: g.Env})`) + setup := []byte("diary.Setup(cfg, diary.FromGlobals(g))") + hook := []byte("bonfire.OnGlobals(diary.ApplyGlobals)") + for _, want := range [][]byte{peek, bootstrap, open, setup, hook, []byte("os.Exit(run(os.Args[1:], os.Stdout))"), []byte("return bonfire.Execute(context.Background(), root, args)")} { + if n := bytes.Count(mainSrc, want); n != 1 { + t.Fatalf("%s count = %d, want 1\n%s", want, n, mainSrc) + } } if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/diary"`)) { t.Fatalf("generated main does not import diary:\n%s", mainSrc) @@ -165,17 +169,18 @@ func TestGenerateMainInstallsDiary(t *testing.T) { if bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) { t.Fatalf("generated main still imports sunscreen:\n%s", mainSrc) } - runSig := []byte("func run(args []string, out io.Writer) error {") + runSig := []byte("func run(args []string, out io.Writer) int {") runStart := bytes.Index(mainSrc, runSig) + pk := bytes.Index(mainSrc, peek) boot := bytes.Index(mainSrc, bootstrap) - load := bytes.Index(mainSrc, []byte("compass.Load(")) + load := bytes.Index(mainSrc, open) set := bytes.Index(mainSrc, setup) newApp := bytes.Index(mainSrc, []byte("backpack.New(")) - if runStart < 0 || load < 0 || newApp < 0 || !(runStart < boot && boot < load && load < set && set < newApp) { - t.Fatalf("want run { Bootstrap; compass.Load; Setup; backpack.New }:\n%s", mainSrc) + if runStart < 0 || newApp < 0 || !(runStart < pk && pk < boot && boot < load && load < set && set < newApp) { + t.Fatalf("want run { PeekGlobals; Bootstrap; compass.Open; Setup; backpack.New }:\n%s", mainSrc) } - if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : boot]); len(between) != 0 { - t.Fatalf("statements before Bootstrap: %q", between) + if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : pk]); len(between) != 0 { + t.Fatalf("statements before PeekGlobals: %q", between) } } diff --git a/internal/build/testdata/new/main.go.golden b/internal/build/testdata/new/main.go.golden index 321653ca..ba4013aa 100644 --- a/internal/build/testdata/new/main.go.golden +++ b/internal/build/testdata/new/main.go.golden @@ -3,7 +3,7 @@ package main import ( - "fmt" + "context" "io" "os" @@ -20,25 +20,23 @@ import ( ) func main() { - if err := run(os.Args[1:], os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + os.Exit(run(os.Args[1:], os.Stdout)) } -func run(args []string, out io.Writer) error { - diary.Bootstrap(os.Stderr, diary.Options{}) - cfg, err := compass.Load("config") +func run(args []string, out io.Writer) int { + g := bonfire.PeekGlobals(args) + diary.Bootstrap(os.Stderr, diary.FromGlobals(g)) + cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env}) if err != nil { - return err + return bonfire.Fail(os.Stderr, "demo", err, g) } - if err := diary.Setup(cfg, diary.Options{}); err != nil { - return err + if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil { + return bonfire.Fail(os.Stderr, "demo", err, g) } app := backpack.New(cfg) plugins, err := party.Activate(app, PluginIDs) if err != nil { - return err + return bonfire.Fail(os.Stderr, "demo", err, g) } commands := lagoon.RuntimeCommands(app, plugins) commands = append(commands, conga.RuntimeCommands(app, plugins)...) @@ -51,12 +49,11 @@ func run(args []string, out io.Writer) error { } } if err := app.Publish(bonfire.NewCatalog(commands)); err != nil { - return err + return bonfire.Fail(os.Stderr, "demo", err, g) } - root, err := bonfire.NewRoot("demo", commands, out) + root, err := bonfire.NewRoot("demo", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals)) if err != nil { - return err + return bonfire.Fail(os.Stderr, "demo", err, g) } - root.SetArgs(args) - return root.Execute() + return bonfire.Execute(context.Background(), root, args) } diff --git a/internal/docsite/check_commands.go b/internal/docsite/check_commands.go index 3b400aef..c8183324 100644 --- a/internal/docsite/check_commands.go +++ b/internal/docsite/check_commands.go @@ -101,10 +101,12 @@ func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) { // command. tool is true for summer and `go run ./cmd/summer`, false for // ./bin/{app} and bin/{app}. ok is false when the line is not one of // those programs or when flags leave no command word. Flag handling -// matches cobra's stripFlags for a root whose only flag is the bool -// --help / -h: "--" ends the search, --help and -h take no value, any -// other --name or two-character -x consumes the next token, and a token -// that already contains "=" is skipped alone. +// matches cobra's stripFlags for the bonfire root and its global options: +// "--" ends the search; --help, -h, -v, -vv, -vvv, --verbose, -q, --quiet, +// -n, --no-interaction, --ansi and --no-ansi take no value; --env takes +// the next token (or --env=X); any other --name or two-character -x +// consumes the next token, and a token that already contains "=" is +// skipped alone. func commandWord(cmd string) (name string, tool bool, ok bool) { fields := strings.Fields(strings.TrimSpace(cmd)) if len(fields) > 0 && fields[0] == "$" { @@ -134,7 +136,7 @@ func commandWord(cmd string) (name string, tool bool, ok bool) { return "", tool, false case strings.HasPrefix(s, "-") && strings.Contains(s, "="): fields = fields[1:] - case s == "--help" || s == "-h": + case valuelessGlobal(s): fields = fields[1:] case strings.HasPrefix(s, "--") || (strings.HasPrefix(s, "-") && len(s) == 2): if len(fields) < 3 { @@ -150,6 +152,18 @@ func commandWord(cmd string) (name string, tool bool, ok bool) { return "", tool, false } +// valuelessGlobal reports the root flags that take no value: --help and -h +// and bonfire's bool global options. "--env" takes a value and is handled +// with the other value flags. +func valuelessGlobal(s string) bool { + switch s { + case "--help", "-h", "-v", "-vv", "-vvv", "--verbose", "-q", "--quiet", + "-n", "--no-interaction", "--ansi", "--no-ansi": + return true + } + return false +} + // isAssignment reports a leading VAR=value token: a name of letters, // digits and underscores that does not start with a digit. func isAssignment(tok string) bool { diff --git a/internal/docsite/checks_test.go b/internal/docsite/checks_test.go index 70efd4b6..7ff70c70 100644 --- a/internal/docsite/checks_test.go +++ b/internal/docsite/checks_test.go @@ -593,6 +593,11 @@ func TestCommandWord(t *testing.T) { {"summer", "", tool, false}, {"FOO=1", "", false, false}, {"summer --version", "", tool, false}, + {"bin/acme -v serve", "serve", app, true}, + {"bin/acme -vvv --env=dev migrate", "migrate", app, true}, + {"bin/acme --env staging migrate", "migrate", app, true}, + {"summer -q --no-ansi -n list", "list", tool, true}, + {"bin/acme --verbose --quiet --no-interaction --ansi help migrate", "help", app, true}, } for _, tc := range cases { name, isTool, ok := commandWord(tc.in) diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index 67146150..fdb0de54 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -30,6 +30,10 @@ Use cases: - Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal. - Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive. - Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description. +- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions. +- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook). +- Exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print as a block on the error stream (`bonfire.RenderError`); `bonfire.Fail` prints errors that happen before the root exists. +- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking. - Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers. - In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command. @@ -67,13 +71,14 @@ func main() { }, } - root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout) + args := os.Args[1:] + g := bonfire.PeekGlobals(args) + root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout, + bonfire.WithVersion(bonfire.BuildVersion())) if err != nil { - os.Exit(1) - } - if err := root.Execute(); err != nil { - os.Exit(1) + os.Exit(bonfire.Fail(os.Stderr, "acme", err, g)) } + os.Exit(bonfire.Execute(context.Background(), root, args)) } ``` @@ -99,8 +104,25 @@ if catalog.Has("blog:import") { | `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. | | `bonfire.Output` | Injected console: printing, status lines, tables, spinner, progress bar and prompts. | | `bonfire.Progress` | A progress bar advanced from inside `bonfire.Output.Progress`. | -| `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin. | +| `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin, with the global options, the `list` command and the command list on a bare call; takes `bonfire.RootOption` values. When the output is the process stdout, the error block goes to stderr. | | `bonfire.NewRootIO` | `bonfire.NewRoot` with injected stdin, stdout and stderr. | +| `bonfire.RootOption` | An option of `bonfire.NewRoot` and `bonfire.NewRootIO`. | +| `bonfire.WithVersion` | Sets the `bonfire.Version` shown in the list header; without it the root reads `bonfire.BuildVersion`. | +| `bonfire.OnGlobals` | Registers a function that receives the parsed `bonfire.Globals` before every command of the root runs. | +| `bonfire.Globals` | The global options: `Verbosity` (0 to 3), `Quiet`, `Color` (a `bonfire.ColorMode`), `NoInteraction` and `Env`. | +| `bonfire.PeekGlobals` | Reads `bonfire.Globals` from raw arguments without failing; stops at `--`. | +| `bonfire.Globals.Args` | Turns `bonfire.Globals` back into flags (`-vv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=`) for forwarding. | +| `bonfire.Version` | Application and framework versions of a binary (`App`, `Framework`, `Local`). | +| `bonfire.BuildVersion` | Reads the `bonfire.Version` from the binary's build information. | +| `bonfire.Version.Header` | The list header: `SummerCMS acme v1.4.0 (framework v0.3.2)`. | +| `bonfire.Execute` | Runs a root with arguments through fang and returns the exit code; prints the list when no command is given. | +| `bonfire.Fail` | Renders an error that happened before the root exists and returns `bonfire.ExitFailure`. | +| `bonfire.ExitOK`, `bonfire.ExitFailure`, `bonfire.ExitUsage` | The exit codes 0, 1 and 2. | +| `bonfire.RenderError` | Writes an error as the console error block. | +| `bonfire.UsageError` | Marks a usage mistake (exit code 2); `bonfire.Usage` wraps an error in it. | +| `bonfire.Usage` | Wraps an error as a `bonfire.UsageError`. | +| `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. | +| `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. | | `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. | | `bonfire.ErrCommandName` | Returned when a plugin command name is not in `namespace:verb` form. | | `bonfire.Call` | Runs one command of a slice by exact name with arguments, writing output to a writer; stdin is empty. | @@ -127,8 +149,8 @@ Without these variables, color is enabled only when stdout is a terminal. ## Dependencies - SummerCMS modules: none. -- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`, `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). -- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. +- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). +- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. ## Testing diff --git a/modules/bonfire/errors.go b/modules/bonfire/errors.go new file mode 100644 index 00000000..1e4a4eb8 --- /dev/null +++ b/modules/bonfire/errors.go @@ -0,0 +1,52 @@ +package bonfire + +import ( + "errors" + "fmt" + "io" + "strings" +) + +// UsageError marks a mistake in how a command was called: an unknown +// command or flag, or a wrong number of arguments. Execute returns ExitUsage +// for it. +type UsageError struct { + Err error +} + +func (e *UsageError) Error() string { return e.Err.Error() } + +func (e *UsageError) Unwrap() error { return e.Err } + +// Usage wraps err as a UsageError; nil stays nil. +func Usage(err error) error { + if err == nil { + return nil + } + var ue *UsageError + if errors.As(err, &ue) { + return err + } + return &UsageError{Err: err} +} + +// isUsage reports a UsageError in the chain or cobra's unknown command error. +func isUsage(err error) bool { + var ue *UsageError + if errors.As(err, &ue) { + return true + } + return strings.HasPrefix(err.Error(), "unknown command") +} + +// RenderError writes err to w as the console error block: a blank line, +// " ERROR " and a blank line. name is the root name of the binary +// (summer, or the application binary's name). +func RenderError(w io.Writer, name string, err error, g Globals) { + if err == nil || w == nil { + return + } + _ = name + _ = g + fmt.Fprintf(w, "\n ERROR %s\n\n", err.Error()) +} diff --git a/modules/bonfire/execute.go b/modules/bonfire/execute.go new file mode 100644 index 00000000..f7259d94 --- /dev/null +++ b/modules/bonfire/execute.go @@ -0,0 +1,101 @@ +package bonfire + +import ( + "context" + "io" + "slices" + "strings" + + "charm.land/fang/v2" + "github.com/charmbracelet/colorprofile" + "github.com/spf13/cobra" +) + +// Exit codes returned by Execute and Fail. +const ( + // ExitOK means the command succeeded. + ExitOK = 0 + // ExitFailure means the command failed. + ExitFailure = 1 + // ExitUsage means the command was called wrongly: an unknown command or + // flag, or a wrong number of arguments. + ExitUsage = 2 +) + +// Execute runs root with args and returns the process exit code: ExitOK, +// ExitFailure, or ExitUsage for usage errors. With no command word in args +// (only global options, -h or --help) it prints the command list. Errors are +// rendered on the root's error stream by RenderError through fang's error +// handler; fang is used without its version flag, man page and completion +// commands. +func Execute(ctx context.Context, root *cobra.Command, args []string) int { + if ctx == nil { + ctx = context.Background() + } + if args == nil { + // cobra reads os.Args when the args are nil. + args = []string{} + } + g := PeekGlobals(args) + if !hasCommandWord(args) { + args = slices.DeleteFunc(slices.Clone(args), func(a string) bool { + return a == "-h" || a == "--help" + }) + } + resetFlags(root) + root.SetArgs(args) + name := root.Name() + err := fang.Execute(ctx, root, + fang.WithoutVersion(), + fang.WithoutManpage(), + fang.WithoutCompletions(), + fang.WithErrorHandler(func(w io.Writer, _ fang.Styles, err error) { + if cw, ok := w.(*colorprofile.Writer); ok { + // RenderError applies its own color profile. + w = cw.Forward + } + RenderError(w, name, err, g) + }), + ) + return exitCode(err) +} + +// Fail renders err like Execute does and returns ExitFailure (ExitOK for a +// nil error). The generated main uses it for errors that happen before the +// command root exists, such as a configuration that does not load. +func Fail(w io.Writer, name string, err error, g Globals) int { + if err == nil { + return ExitOK + } + RenderError(w, name, err, g) + return ExitFailure +} + +func exitCode(err error) int { + switch { + case err == nil: + return ExitOK + case isUsage(err): + return ExitUsage + default: + return ExitFailure + } +} + +// hasCommandWord reports whether args, before "--", hold a token that is +// neither a flag nor the value of --env. +func hasCommandWord(args []string) bool { + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + return false + case a == "--"+flagEnv: + i++ + case strings.HasPrefix(a, "-"): + default: + return true + } + } + return false +} diff --git a/modules/bonfire/globals.go b/modules/bonfire/globals.go new file mode 100644 index 00000000..546e1b4b --- /dev/null +++ b/modules/bonfire/globals.go @@ -0,0 +1,206 @@ +package bonfire + +import ( + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// maxVerbosity caps -v, -vv and -vvv (and repeated --verbose). +const maxVerbosity = 3 + +// Globals are the global console options every command accepts: -v, -vv and +// -vvv (--verbose), -q (--quiet), --ansi and --no-ansi, -n +// (--no-interaction) and --env. +type Globals struct { + // Verbosity is 0 by default and 1 to 3 for -v, -vv and -vvv. + Verbosity int + // Quiet suppresses decoration and info lines; it implies NoInteraction. + Quiet bool + // Color is ColorAlways for --ansi, ColorNever for --no-ansi and + // ColorAuto otherwise; the last of the two flags wins. + Color ColorMode + // NoInteraction makes prompts return their defaults without asking. + NoInteraction bool + // Env is the --env value: the environment the command runs under. + Env string +} + +// Global option names and shorthands, reserved on every command. +const ( + flagVerbose = "verbose" + flagQuiet = "quiet" + flagANSI = "ansi" + flagNoANSI = "no-ansi" + flagNoInteraction = "no-interaction" + flagEnv = "env" + flagHelp = "help" +) + +var ( + reservedFlagNames = []string{flagVerbose, flagQuiet, flagANSI, flagNoANSI, flagNoInteraction, flagEnv, flagHelp} + reservedFlagShorthands = []string{"v", "q", "n", "h"} +) + +// PeekGlobals reads the global options from args before the command root +// exists, so the generated main can pick the configuration environment and +// the log verbosity before config loads and plugins boot. The scan is +// tolerant: it never fails, ignores every other token and stops at "--". +// Quiet also sets NoInteraction. +func PeekGlobals(args []string) Globals { + var g Globals + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + return g.normalize() + case a == "--"+flagVerbose: + g.Verbosity++ + case a == "-q" || a == "--"+flagQuiet: + g.Quiet = true + case a == "-n" || a == "--"+flagNoInteraction: + g.NoInteraction = true + case a == "--"+flagANSI: + g.Color = ColorAlways + case a == "--"+flagNoANSI: + g.Color = ColorNever + case a == "--"+flagEnv: + if i+1 < len(args) { + g.Env = args[i+1] + i++ + } + case strings.HasPrefix(a, "--"+flagEnv+"="): + g.Env = strings.TrimPrefix(a, "--"+flagEnv+"=") + case isVerboseShorthand(a): + g.Verbosity += len(a) - 1 + } + } + return g.normalize() +} + +func isVerboseShorthand(a string) bool { + if len(a) < 2 || a[0] != '-' || a[1] == '-' { + return false + } + return strings.Trim(a[1:], "v") == "" +} + +func (g Globals) normalize() Globals { + g.Verbosity = min(max(g.Verbosity, 0), maxVerbosity) + if g.Quiet { + g.NoInteraction = true + } + return g +} + +// Args re-serialises the globals as command-line flags, for forwarding to +// another program: -v, -vv or -vvv, -q, -n, --ansi or --no-ansi and +// --env=. The zero value gives no arguments. +func (g Globals) Args() []string { + var out []string + if v := min(g.Verbosity, maxVerbosity); v > 0 { + out = append(out, "-"+strings.Repeat("v", v)) + } + if g.Quiet { + out = append(out, "-q") + } + if g.NoInteraction && !g.Quiet { + out = append(out, "-n") + } + switch g.Color { + case ColorAlways: + out = append(out, "--"+flagANSI) + case ColorNever: + out = append(out, "--"+flagNoANSI) + } + if g.Env != "" { + out = append(out, "--"+flagEnv+"="+g.Env) + } + return out +} + +// globalFlags holds the parsed persistent flags of one root. +type globalFlags struct { + verbosity int + quiet bool + color ColorMode + noInteraction bool + env string +} + +// colorFlag is --ansi or --no-ansi: both write one shared ColorMode, so the +// last flag on the command line wins. +type colorFlag struct { + mode *ColorMode + set ColorMode +} + +func (f colorFlag) String() string { + return strconv.FormatBool(*f.mode == f.set) +} + +func (f colorFlag) Set(s string) error { + on, err := strconv.ParseBool(s) + if err != nil { + return err + } + switch { + case on: + *f.mode = f.set + case *f.mode == f.set: + *f.mode = ColorAuto + } + return nil +} + +func (f colorFlag) Type() string { return "bool" } + +// register adds the global options to the root's persistent flags. +func (gf *globalFlags) register(fs *pflag.FlagSet) { + fs.CountVarP(&gf.verbosity, flagVerbose, "v", "Increase verbosity (-v, -vv or -vvv)") + fs.BoolVarP(&gf.quiet, flagQuiet, "q", false, "Do not output any message") + fs.Var(colorFlag{mode: &gf.color, set: ColorAlways}, flagANSI, "Force ANSI output") + fs.Lookup(flagANSI).NoOptDefVal = "true" + fs.Var(colorFlag{mode: &gf.color, set: ColorNever}, flagNoANSI, "Disable ANSI output") + fs.Lookup(flagNoANSI).NoOptDefVal = "true" + fs.BoolVarP(&gf.noInteraction, flagNoInteraction, "n", false, "Do not ask any interactive question") + fs.StringVar(&gf.env, flagEnv, "", "The environment the command runs under") +} + +func (gf *globalFlags) globals() Globals { + return Globals{ + Verbosity: gf.verbosity, + Quiet: gf.quiet, + Color: gf.color, + NoInteraction: gf.noInteraction, + Env: gf.env, + }.normalize() +} + +// resetFlags puts every persistent flag of root back to its default, so a +// root executed twice (tests, Execute) does not keep the previous run's +// globals: pflag count flags add to their current value. +func resetFlags(root *cobra.Command) { + root.PersistentFlags().VisitAll(func(f *pflag.Flag) { + _ = f.Value.Set(f.DefValue) + f.Changed = false + }) +} + +// globalOption is one line of the global options block in list and help. +type globalOption struct { + flags string + description string +} + +// globalOptions are the global option lines of the list (D-13). +var globalOptions = []globalOption{ + {"-h, --help", "Display help for the given command"}, + {"-q, --quiet", "Do not output any message"}, + {"-v|vv|vvv", "Increase verbosity"}, + {" --ansi|no-ansi", "Force (or disable) ANSI output"}, + {"-n, --no-interaction", "Do not ask any interactive question"}, + {" --env", "The environment the command runs under"}, +} diff --git a/modules/bonfire/list.go b/modules/bonfire/list.go new file mode 100644 index 00000000..4a2af9b3 --- /dev/null +++ b/modules/bonfire/list.go @@ -0,0 +1,149 @@ +package bonfire + +import ( + "context" + "fmt" + "io" + "slices" + "strings" + + "github.com/charmbracelet/colorprofile" +) + +// builtinCommands describes the root's own commands, help and list. They are +// registered by NewRoot, so plugins cannot use their names. +func builtinCommands() []Command { + return []Command{ + { + Name: "help", + Description: "Display help for a command", + Args: []Arg{{Name: "command_name", Description: "The command name"}}, + }, + { + Name: "list", + Description: "List commands", + Flags: []Flag{ + {Name: "all", Description: "Show hidden commands too", Bare: true}, + {Name: "format", Description: "The output format (txt or json)", Default: "txt"}, + }, + }, + } +} + +func builtinCommand(name string) Command { + for _, c := range builtinCommands() { + if c.Name == name { + return c + } + } + return Command{Name: name} +} + +// usageName is how a binary is invoked in usage lines and examples: summer +// for the tool, bin/ for an application binary. +func usageName(name string) string { + if name == "summer" { + return name + } + return "bin/" + name +} + +// lister renders the command list of one root. +type lister struct { + name string + commands []Command + version Version + out io.Writer + console *console +} + +// command is the list command with its run function. +func (l *lister) command() Command { + c := builtinCommand("list") + c.Run = func(_ context.Context, in Input, _ Output) error { + all, _ := in.Flag("all") + format, _ := in.Flag("format") + switch format { + case "", "txt": + return l.render(l.console.globals, all == "true") + default: + return Usage(fmt.Errorf("list: unknown format %q (use txt or json)", format)) + } + } + return c +} + +// all returns the root's commands and its builtins. +func (l *lister) all() []Command { + return append(append([]Command(nil), l.commands...), builtinCommands()...) +} + +// render writes the Artisan list: the version header, usage, the global +// options and the commands, top-level names first and then one block per +// namespace (the text before the first colon). +func (l *lister) render(g Globals, all bool) error { + p := DefaultPalette() + var top []Command + groups := map[string][]Command{} + width := 0 + for _, c := range l.all() { + ns, _, namespaced := strings.Cut(c.Name, ":") + if namespaced { + groups[ns] = append(groups[ns], c) + } else { + top = append(top, c) + } + width = max(width, len(c.Name)) + } + byName := func(a, b Command) int { return strings.Compare(a.Name, b.Name) } + slices.SortFunc(top, byName) + namespaces := make([]string, 0, len(groups)) + for ns := range groups { + namespaces = append(namespaces, ns) + } + slices.Sort(namespaces) + + var b strings.Builder + b.WriteString(p.Header.Render(l.version.Header(l.name)) + "\n\n") + b.WriteString(p.Header.Render("Usage:") + "\n") + b.WriteString(" " + usageName(l.name) + " [options] [arguments]\n\n") + b.WriteString(p.Header.Render("Options:") + "\n") + writeGlobalOptions(&b, p) + b.WriteString("\n" + p.Header.Render("Available commands:") + "\n") + line := func(c Command) { + pad := strings.Repeat(" ", width-len(c.Name)+2) + b.WriteString(" " + p.Success.Render(c.Name) + pad + c.Description + "\n") + } + for _, c := range top { + line(c) + } + for _, ns := range namespaces { + b.WriteString(" " + p.Header.Render(ns) + "\n") + cmds := groups[ns] + slices.SortFunc(cmds, byName) + for _, c := range cmds { + line(c) + } + } + return writeStyled(l.out, g, b.String()) +} + +// writeGlobalOptions writes the global option lines, aligned. +func writeGlobalOptions(b *strings.Builder, p Palette) { + width := 0 + for _, o := range globalOptions { + width = max(width, len(o.flags)) + } + for _, o := range globalOptions { + pad := strings.Repeat(" ", width-len(o.flags)+2) + b.WriteString(" " + p.Success.Render(o.flags) + pad + o.description + "\n") + } +} + +// writeStyled writes s to w through a colorprofile writer, so palette +// styles follow --ansi, --no-ansi, NO_COLOR and the terminal. +func writeStyled(w io.Writer, g Globals, s string) error { + cw := &colorprofile.Writer{Forward: w, Profile: DetectProfile(w, g.Color, nil)} + _, err := io.WriteString(cw, s) + return err +} diff --git a/modules/bonfire/list_smoke_test.go b/modules/bonfire/list_smoke_test.go new file mode 100644 index 00000000..5de9f602 --- /dev/null +++ b/modules/bonfire/list_smoke_test.go @@ -0,0 +1,142 @@ +package bonfire + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" +) + +// TestListTracerSmoke: a bare root prints the Artisan list (header, usage, +// global options, top-level commands then namespaces), the global options +// reach the OnGlobals hook the same way PeekGlobals reads them, and Execute +// returns 0, 1 or 2. +func TestListTracerSmoke(t *testing.T) { + var recorded []Globals + commands := func() []Command { + ok := func(context.Context, Input, Output) error { return nil } + return []Command{ + {Name: "migrate", Description: "Run the database migrations", Run: ok}, + {Name: "admin:create", Description: "Create a backend administrator", Run: ok}, + {Name: "admin:token:create", Description: "Create a personal API token", Run: ok}, + {Name: "make:model", Description: "Create a new model and migration", Run: ok}, + {Name: "acme:fail", Description: "Fail on purpose", Run: func(context.Context, Input, Output) error { + return errors.New("boom") + }}, + } + } + newRoot := func(t *testing.T, out *bytes.Buffer) func(args ...string) int { + t.Helper() + root, err := NewRootIO("acme", commands(), strings.NewReader(""), out, out, + WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"}), + OnGlobals(func(g Globals) { recorded = append(recorded, g) })) + if err != nil { + t.Fatal(err) + } + return func(args ...string) int { + recorded = nil + out.Reset() + return Execute(context.Background(), root, args) + } + } + + var out bytes.Buffer + run := newRoot(t, &out) + if code := run(); code != ExitOK { + t.Fatalf("bare root exit = %d\n%s", code, out.String()) + } + got := out.String() + for _, want := range []string{ + "SummerCMS acme v1.4.0 (framework v0.3.2)\n", + "Usage:\n bin/acme [options] [arguments]\n", + " -h, --help Display help for the given command\n", + " -q, --quiet Do not output any message\n", + " -v|vv|vvv Increase verbosity\n", + " --ansi|no-ansi Force (or disable) ANSI output\n", + " -n, --no-interaction Do not ask any interactive question\n", + " --env The environment the command runs under\n", + "Available commands:\n", + " migrate Run the database migrations\n", + " admin:token:create Create a personal API token\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("list missing %q:\n%s", want, got) + } + } + order := []string{"\n list ", "\n migrate ", "\n acme\n", "\n admin\n", "\n admin:create ", "\n admin:token:create ", "\n make\n", "\n make:model "} + last := -1 + for _, s := range order { + i := strings.Index(got, s) + if i <= last { + t.Fatalf("%q out of order (at %d, previous at %d):\n%s", s, i, last, got) + } + last = i + } + if strings.Contains(got, "\x1b[") { + t.Fatalf("non-tty list has ansi:\n%q", got) + } + if code := run("--help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") { + t.Fatalf("--help exit = %d\n%s", code, out.String()) + } + + cases := []struct { + args []string + want Globals + }{ + {[]string{"-vvv", "migrate"}, Globals{Verbosity: 3}}, + {[]string{"migrate", "-q"}, Globals{Quiet: true, NoInteraction: true}}, + {[]string{"migrate", "--env", "staging", "-n", "--ansi", "--no-ansi"}, Globals{NoInteraction: true, Env: "staging", Color: ColorNever}}, + {[]string{"--env=dev", "-v", "migrate", "--verbose"}, Globals{Verbosity: 2, Env: "dev"}}, + } + for _, c := range cases { + if code := run(c.args...); code != ExitOK { + t.Fatalf("%v exit = %d\n%s", c.args, code, out.String()) + } + if len(recorded) != 1 || recorded[0] != c.want { + t.Fatalf("%v: hook got %+v, want %+v", c.args, recorded, c.want) + } + if peek := PeekGlobals(c.args); peek != c.want { + t.Fatalf("%v: PeekGlobals = %+v, want %+v", c.args, peek, c.want) + } + } + + if code := run("migrat"); code != ExitUsage { + t.Fatalf("unknown command exit = %d, want %d\n%s", code, ExitUsage, out.String()) + } + if code := run("migrate", "--bogus"); code != ExitUsage { + t.Fatalf("unknown flag exit = %d, want %d", code, ExitUsage) + } + if code := run("acme:fail"); code != ExitFailure || !strings.Contains(out.String(), "ERROR") || !strings.Contains(out.String(), "boom") { + t.Fatalf("failing command exit = %d\n%s", code, out.String()) + } + + dup := append(commands(), Command{Name: "migrate", Description: "Again"}) + if _, err := NewRootIO("acme", dup, strings.NewReader(""), &out, &out); !errors.Is(err, ErrDuplicateCommand) || !strings.Contains(err.Error(), `"migrate"`) { + t.Fatalf("duplicate name err = %v", err) + } + if _, err := NewRootIO("acme", []Command{{Name: "acme:list"}, {Name: "list"}}, strings.NewReader(""), &out, &out); !errors.Is(err, ErrDuplicateCommand) { + t.Fatalf("command named list err = %v", err) + } + reserved := []Command{{Name: "acme:loud", Flags: []Flag{{Name: "volume", Shorthand: "v"}}}} + if _, err := NewRootIO("acme", reserved, strings.NewReader(""), &out, &out); !errors.Is(err, ErrReservedFlag) || !strings.Contains(err.Error(), "acme:loud") { + t.Fatalf("reserved shorthand err = %v", err) + } + reservedName := []Command{{Name: "acme:loud", Flags: []Flag{{Name: "env"}}}} + if _, err := NewRootIO("acme", reservedName, strings.NewReader(""), &out, &out); !errors.Is(err, ErrReservedFlag) { + t.Fatalf("reserved name err = %v", err) + } + + // Call builds its own root and never runs a hook. + recorded = nil + if err := Call(context.Background(), commands(), "migrate", []string{"-vv"}, &out); err != nil || len(recorded) != 0 { + t.Fatalf("Call: err %v, hooks %v", err, recorded) + } + + if args := (Globals{Verbosity: 2, Quiet: true, NoInteraction: true, Color: ColorNever, Env: "staging"}).Args(); strings.Join(args, " ") != "-vv -q --no-ansi --env=staging" { + t.Fatalf("Globals.Args = %v", args) + } + if args := (Globals{}).Args(); len(args) != 0 { + t.Fatalf("zero Globals.Args = %v", args) + } +} diff --git a/modules/bonfire/output.go b/modules/bonfire/output.go index afac4dc8..31cf0b6a 100644 --- a/modules/bonfire/output.go +++ b/modules/bonfire/output.go @@ -55,6 +55,9 @@ type console struct { tty bool interactive bool color bool + // globals are the parsed global options of the running command, set + // by the root before the command runs. + globals Globals } // NewOutput builds Output from injected streams and the process color policy. @@ -167,4 +170,9 @@ func (c *console) dim(s string) string { return c.style(ansiDim, s) } +// setGlobals records the parsed global options of the running command. +func (c *console) setGlobals(g Globals) { + c.globals = g +} + func boolPtr(v bool) *bool { return &v } diff --git a/modules/bonfire/root.go b/modules/bonfire/root.go index 81980a80..50dfaf00 100644 --- a/modules/bonfire/root.go +++ b/modules/bonfire/root.go @@ -1,21 +1,70 @@ package bonfire import ( + "errors" "fmt" "io" "os" + "slices" "github.com/spf13/cobra" ) +// ErrDuplicateCommand is returned by NewRoot when two commands share a name, +// or a command is named list or help (the root's own commands). +var ErrDuplicateCommand = errors.New("bonfire: duplicate command name") + +// ErrReservedFlag is returned by NewRoot when a command flag reuses the name +// or shorthand of a global option (verbose, quiet, ansi, no-ansi, +// no-interaction, env, help; -v, -q, -n, -h). +var ErrReservedFlag = errors.New("bonfire: flag name is reserved for a global option") + +// RootOption configures NewRoot and NewRootIO. +type RootOption func(*rootConfig) + +type rootConfig struct { + version Version + hasVersion bool + hooks []func(Globals) +} + +// WithVersion sets the version shown in the header of the command list. +// Without it the root reads BuildVersion. +func WithVersion(v Version) RootOption { + return func(c *rootConfig) { + c.version = v + c.hasVersion = true + } +} + +// OnGlobals registers fn to run with the parsed global options before every +// command of the root runs. The generated main uses it to re-apply the +// options to diary once cobra has parsed them; roots built by Call never run +// a hook, so a scheduled command cannot change process-wide settings. +func OnGlobals(fn func(Globals)) RootOption { + return func(c *rootConfig) { + if fn != nil { + c.hooks = append(c.hooks, fn) + } + } +} + // NewRoot builds the shared cobra root used by the summer tool and app binaries. // It accepts command values rather than a party registry to avoid an import cycle. -func NewRoot(name string, commands []Command, out io.Writer) (*cobra.Command, error) { - return NewRootIO(name, commands, os.Stdin, out, out) +// When out is the process stdout, the error block of Execute goes to stderr. +func NewRoot(name string, commands []Command, out io.Writer, opts ...RootOption) (*cobra.Command, error) { + root, err := NewRootIO(name, commands, os.Stdin, out, out, opts...) + if err != nil { + return nil, err + } + if out == nil || out == os.Stdout { + root.SetErr(os.Stderr) + } + return root, nil } // NewRootIO is NewRoot with injected stdin/stdout/stderr for tests. -func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer) (*cobra.Command, error) { +func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer, opts ...RootOption) (*cobra.Command, error) { if out == nil { out = os.Stdout } @@ -25,7 +74,16 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write if in == nil { in = os.Stdin } - output := NewOutput(in, out, errW) + cfg := &rootConfig{} + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + if !cfg.hasVersion { + cfg.version = BuildVersion() + } + output := newConsole(in, out, errW, os.Getenv, nil, nil) root := &cobra.Command{ Use: name, SilenceUsage: true, @@ -37,25 +95,76 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write root.SetIn(in) root.SetOut(out) root.SetErr(errW) + flags := &globalFlags{} + flags.register(root.PersistentFlags()) + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return Usage(err) + }) + + seen := map[string]bool{} + for _, b := range builtinCommands() { + seen[b.Name] = true + } for _, command := range commands { + if seen[command.Name] { + return nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name) + } + seen[command.Name] = true cmd, err := wrap(command, output) if err != nil { return nil, err } root.AddCommand(cmd) } + + lister := &lister{name: name, commands: commands, version: cfg.version, out: out, console: output} + listCmd, err := wrapCommand(lister.command(), output, false) + if err != nil { + return nil, err + } + root.AddCommand(listCmd) + root.RunE = func(cmd *cobra.Command, _ []string) error { + return lister.render(output.globals, false) + } + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + var g Globals + if cmd.DisableFlagParsing { + // Passthrough commands parse no flags; read the globals the + // way the generated main did before boot. + g = PeekGlobals(args) + } else { + g = flags.globals() + } + output.setGlobals(g) + for _, hook := range cfg.hooks { + hook(g) + } + return nil + } return root, nil } func wrap(c Command, out Output) (*cobra.Command, error) { - if !validCommandName(c.Name) { + return wrapCommand(c, out, true) +} + +func wrapCommand(c Command, out Output, validate bool) (*cobra.Command, error) { + if validate && !validCommandName(c.Name) { return nil, fmt.Errorf("%w: %q", ErrCommandName, c.Name) } + for _, flag := range c.Flags { + if slices.Contains(reservedFlagNames, flag.Name) { + return nil, fmt.Errorf("%w: command %q declares --%s", ErrReservedFlag, c.Name, flag.Name) + } + if flag.Shorthand != "" && slices.Contains(reservedFlagShorthands, flag.Shorthand) { + return nil, fmt.Errorf("%w: command %q declares -%s for --%s", ErrReservedFlag, c.Name, flag.Shorthand, flag.Name) + } + } cmd := &cobra.Command{ Use: commandUsage(c), Short: c.Description, Long: c.Long, - Args: positionalArgs(c.Args), + Args: usageArgs(positionalArgs(c.Args)), RunE: func(cmd *cobra.Command, args []string) error { if c.Run == nil { return nil @@ -98,3 +207,10 @@ func wrap(c Command, out Output) (*cobra.Command, error) { } return cmd, nil } + +// usageArgs marks argument-count failures as usage errors (exit code 2). +func usageArgs(fn cobra.PositionalArgs) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + return Usage(fn(cmd, args)) + } +} diff --git a/modules/bonfire/version.go b/modules/bonfire/version.go new file mode 100644 index 00000000..ddae0cc8 --- /dev/null +++ b/modules/bonfire/version.go @@ -0,0 +1,73 @@ +package bonfire + +import ( + "runtime/debug" +) + +// frameworkModule is the module path of the SummerCMS framework. +const frameworkModule = "git.golem15.com/golem15/summercms" + +// Version is the version line of a binary: the application's own version and +// the framework version it was built against. +type Version struct { + // App is the application module version ("devel" for an untagged + // local build). + App string + // Framework is the framework module version; it equals App when the + // binary is the framework's own summer tool. + Framework string + // Local is true when the framework comes from a replace directive + // (a local checkout) rather than the module proxy. + Local bool +} + +// BuildVersion reads the versions from the binary's build information +// (runtime/debug.ReadBuildInfo): App is the main module version and Framework +// the version of the git.golem15.com/golem15/summercms dependency, or App +// when the main module is the framework itself. "(devel)" reads as "devel". +func BuildVersion() Version { + info, ok := debug.ReadBuildInfo() + if !ok { + return Version{App: "devel", Framework: "devel"} + } + v := Version{App: cleanVersion(info.Main.Version)} + if info.Main.Path == frameworkModule { + v.Framework = v.App + return v + } + v.Framework = "devel" + for _, dep := range info.Deps { + if dep.Path != frameworkModule { + continue + } + v.Framework = cleanVersion(dep.Version) + v.Local = dep.Replace != nil + break + } + return v +} + +func cleanVersion(s string) string { + if s == "" || s == "(devel)" { + return "devel" + } + return s +} + +// Header is the first line of the command list: "SummerCMS acme v1.4.0 +// (framework v0.3.2)", with ", local" added inside the parentheses when the +// framework is a local checkout, and "SummerCMS summer v0.3.2" when App and +// Framework are the same build. +func (v Version) Header(name string) string { + app := cleanVersion(v.App) + line := "SummerCMS " + name + " " + app + fw := v.Framework + if fw == "" || (fw == v.App && !v.Local) { + return line + } + fw = "framework " + cleanVersion(fw) + if v.Local { + fw += ", local" + } + return line + " (" + fw + ")" +} diff --git a/modules/diary/README.md b/modules/diary/README.md index 7f675af8..9bffa32f 100644 --- a/modules/diary/README.md +++ b/modules/diary/README.md @@ -10,7 +10,7 @@ Application logging modelled on Laravel's logging config: named channels (consol `diary` owns the process logger of a SummerCMS application. `config/log.yaml` names a default channel, a global level and a map of channels, the way Laravel's `config/logging.php` does: a console channel that prints styled lines on a terminal, daily files with retention, and a stack that fans a record out to several channels. Code logs through named component loggers (`diary.For`), so every record carries the plugin or module it came from. Every channel is wrapped by `sunscreen.Wrap`, so credentials are redacted whichever channel a record reaches. -The application `main` that `summer build` generates calls `diary.Bootstrap` as its first statement, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`. +The application `main` that `summer build` generates reads the console global options first (`bonfire.PeekGlobals`), calls `diary.Bootstrap` with them, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`. Once the command line is parsed, `diary.ApplyGlobals` re-applies `-v`, `-q` and `--ansi`/`--no-ansi`. ## Role in the framework @@ -33,6 +33,7 @@ Use cases: - Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up. - Redaction: every channel leaf is wrapped by `sunscreen.Wrap`. - Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn. +- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`). - The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component. ## Usage @@ -58,16 +59,20 @@ and writes warnings and errors as JSON to `storage/logs/summer-YYYY-MM-DD.log`. The generated `main` installs it: ```go -func run(args []string, out io.Writer) error { - diary.Bootstrap(os.Stderr, diary.Options{}) - cfg, err := compass.Load("config") +func run(args []string, out io.Writer) int { + g := bonfire.PeekGlobals(args) + diary.Bootstrap(os.Stderr, diary.FromGlobals(g)) + cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env}) if err != nil { - return err + return bonfire.Fail(os.Stderr, "acme", err, g) } - if err := diary.Setup(cfg, diary.Options{}); err != nil { - return err + if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil { + return bonfire.Fail(os.Stderr, "acme", err, g) } // ... + root, err := bonfire.NewRoot("acme", commands, out, + bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals)) + // ... } ``` @@ -81,6 +86,8 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar | `diary.Bootstrap` | Installs a console channel on a writer before configuration is read and makes diary the process default logger. | | `diary.Setup` | Parses `log.*` from a `compass` configuration, builds the channels and installs them; returns configuration errors. | | `diary.SetVerbosity` | Changes verbosity and quiet at runtime. | +| `diary.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`. | +| `diary.ApplyGlobals` | Applies parsed `bonfire.Globals` to the running setup: verbosity and quiet, and the console color mode when it changed. | | `diary.Verbosity` | Returns the current verbosity. | | `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. | | `diary.Close` | Closes the open daily files; later records open them again. | diff --git a/modules/diary/diary.go b/modules/diary/diary.go index 3e96aa53..244815c2 100644 --- a/modules/diary/diary.go +++ b/modules/diary/diary.go @@ -70,6 +70,8 @@ type state struct { verbosity int quiet bool channels *channels + conf Config + opts Options } // floor is the minimum level for records of component name: its @@ -165,6 +167,8 @@ func install(conf Config, ch *channels, opts Options) { verbosity: opts.Verbosity, quiet: opts.Quiet, channels: ch, + conf: conf, + opts: opts, } old := current.Swap(st) slog.SetDefault(slog.New(newComponentHandler(""))) @@ -188,6 +192,7 @@ func SetVerbosity(v int, quiet bool) { } next := *st next.verbosity, next.quiet = v, quiet + next.opts.Verbosity, next.opts.Quiet = v, quiet for _, c := range st.channels.consoles { c.apply(quiet) } @@ -195,6 +200,31 @@ func SetVerbosity(v int, quiet bool) { current.Store(&next) } +// FromGlobals returns the Options for the console global options: their +// verbosity, quiet switch and color mode. The generated main passes it to +// Bootstrap and Setup. +func FromGlobals(g bonfire.Globals) Options { + return Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color} +} + +// ApplyGlobals applies the parsed global options to the installed setup: +// the verbosity and quiet switch (SetVerbosity) and, when it differs from +// the one the channels were built with, the console color mode, which +// rebuilds the channels from the same configuration. The generated main +// registers it with bonfire.OnGlobals. +func ApplyGlobals(g bonfire.Globals) { + st := current.Load() + if st != nil && st.opts.Color != g.Color { + opts := st.opts + opts.Color, opts.Verbosity, opts.Quiet = g.Color, g.Verbosity, g.Quiet + if ch, err := buildChannels(st.conf, opts); err == nil { + install(st.conf, ch, opts) + return + } + } + SetVerbosity(g.Verbosity, g.Quiet) +} + // Verbosity returns the current verbosity (0 by default). func Verbosity() int { return int(verbosity.Load())