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