feat(bonfire): global flags, Artisan list and exit codes

- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every
  command; PeekGlobals reads them before config loads, OnGlobals hooks
  re-apply them after parsing (diary.ApplyGlobals in the generated main)
- Bare binary, --help and list print the Artisan list with the version
  header from BuildVersion and namespace blocks
- Execute runs the root through fang (no version, man page or completions)
  and returns 0, 1 or 2; Fail renders errors before the root exists
- NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags
  that reuse a global option (ErrReservedFlag)
- Generated main and summer use PeekGlobals, compass.Open with --env,
  diary.FromGlobals and bonfire.Execute; docs checker knows the globals
This commit is contained in:
Jakub Zych
2026-10-21 12:13:23 +02:00
parent b7c07591cb
commit 8d4d7ce7a7
25 changed files with 1158 additions and 154 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 <command> [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/<binary>` when it does not exist yet, and run the same command of the binary with the same arguments:

View File

@@ -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`.

View File

@@ -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)
}
}

View File

@@ -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)
}

8
go.mod
View File

@@ -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

16
go.sum
View File

@@ -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=

View File

@@ -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()))
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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=<value>`) for forwarding. |
| `bonfire.Version` | Application and framework versions of a binary (`App`, `Framework`, `Local`). |
| `bonfire.BuildVersion` | Reads the `bonfire.Version` from the binary's build information. |
| `bonfire.Version.Header` | The list header: `SummerCMS acme v1.4.0 (framework v0.3.2)`. |
| `bonfire.Execute` | Runs a root with arguments through fang and returns the exit code; prints the list when no command is given. |
| `bonfire.Fail` | Renders an error that happened before the root exists and returns `bonfire.ExitFailure`. |
| `bonfire.ExitOK`, `bonfire.ExitFailure`, `bonfire.ExitUsage` | The exit codes 0, 1 and 2. |
| `bonfire.RenderError` | Writes an error as the console error block. |
| `bonfire.UsageError` | Marks a usage mistake (exit code 2); `bonfire.Usage` wraps an error in it. |
| `bonfire.Usage` | Wraps an error as a `bonfire.UsageError`. |
| `bonfire.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

52
modules/bonfire/errors.go Normal file
View File

@@ -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 <message>" 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())
}

101
modules/bonfire/execute.go Normal file
View File

@@ -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
}

206
modules/bonfire/globals.go Normal file
View File

@@ -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=<value>. 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"},
}

149
modules/bonfire/list.go Normal file
View File

@@ -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/<name> 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) + " <command> [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
}

View File

@@ -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 <command> [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)
}
}

View File

@@ -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 }

View File

@@ -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))
}
}

View File

@@ -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 + ")"
}

View File

@@ -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. |

View File

@@ -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())