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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
8
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
|
||||
|
||||
16
go.sum
16
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=
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
internal/build/testdata/new/main.go.golden
vendored
31
internal/build/testdata/new/main.go.golden
vendored
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
52
modules/bonfire/errors.go
Normal 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
101
modules/bonfire/execute.go
Normal 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
206
modules/bonfire/globals.go
Normal 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
149
modules/bonfire/list.go
Normal 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
|
||||
}
|
||||
142
modules/bonfire/list_smoke_test.go
Normal file
142
modules/bonfire/list_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
73
modules/bonfire/version.go
Normal file
73
modules/bonfire/version.go
Normal 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 + ")"
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user