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

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