Files
summercms/modules/bonfire/root.go
Jakub Zych fb32886775 feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
2026-10-21 12:21:24 +02:00

280 lines
7.5 KiB
Go

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.
// 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, opts ...RootOption) (*cobra.Command, error) {
if out == nil {
out = os.Stdout
}
if errW == nil {
errW = out
}
if in == nil {
in = os.Stdin
}
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,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
root.SetIn(in)
root.SetOut(out)
root.SetErr(errW)
flags := &globalFlags{}
flags.register(root.PersistentFlags())
root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
return usageFor(c, root, err)
})
st := &rootState{
root: root,
name: name,
commands: commands,
version: cfg.version,
out: out,
console: output,
flags: flags,
}
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 := st.wrap(command, true)
if err != nil {
return nil, err
}
root.AddCommand(cmd)
}
listCmd, err := st.wrap(st.listCommand(), false)
if err != nil {
return nil, err
}
root.AddCommand(listCmd)
helpCmd, err := st.wrap(st.helpCommand(), false)
if err != nil {
return nil, err
}
root.SetHelpCommand(helpCmd)
root.SetHelpFunc(func(*cobra.Command, []string) {
_ = st.renderList(st.globals(), false)
})
root.RunE = func(cmd *cobra.Command, _ []string) error {
return st.renderList(st.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
}
// rootState is what the list, help and error rendering of one root share.
type rootState struct {
root *cobra.Command
name string
commands []Command
version Version
out io.Writer
console *console
flags *globalFlags
}
// globals returns the parsed global options of the current run.
func (st *rootState) globals() Globals {
return st.flags.globals()
}
// wrap turns a command value into a cobra command. validate checks the
// name form (the builtins list and help skip it).
func (st *rootState) wrap(c Command, validate bool) (*cobra.Command, error) {
cmd, err := wrapCommand(c, st.console, validate)
if err != nil {
return nil, err
}
cmd.Hidden = c.Hidden
cmd.SetHelpFunc(func(*cobra.Command, []string) {
_ = st.renderHelp(c, st.globals())
})
return cmd, nil
}
func wrap(c Command, out Output) (*cobra.Command, error) {
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: usageArgs(positionalArgs(c.Args)),
RunE: func(cmd *cobra.Command, args []string) error {
if c.Run == nil {
return nil
}
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out)
},
}
if c.Passthrough {
// Every argument, flags included, reaches Run as given.
cmd.DisableFlagParsing = true
cmd.Args = cobra.ArbitraryArgs
}
for _, flag := range c.Flags {
if flag.Repeatable {
// D-19: an ordered, multi-occurrence string flag (Cobra
// StringSlice), read back through Input.Flags. Default is a
// single-element slice only when non-empty, matching the
// scalar path's zero-value convention.
var def []string
if flag.Default != "" {
def = []string{flag.Default}
}
if flag.Shorthand != "" {
cmd.Flags().StringSliceP(flag.Name, flag.Shorthand, def, flag.Description)
} else {
cmd.Flags().StringSlice(flag.Name, def, flag.Description)
}
continue
}
if flag.Shorthand != "" {
cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description)
} else {
cmd.Flags().String(flag.Name, flag.Default, flag.Description)
}
if flag.Bare {
if f := cmd.Flags().Lookup(flag.Name); f != nil {
f.NoOptDefVal = "true"
}
}
}
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 usageFor(cmd, cmd.Root(), fn(cmd, args))
}
}
// usageFor wraps err as a UsageError that remembers the command, so the
// error block can suggest its help.
func usageFor(cmd, root *cobra.Command, err error) error {
if err == nil {
return nil
}
var ue *UsageError
if errors.As(err, &ue) {
return err
}
ue = &UsageError{Err: err}
if cmd != nil && cmd != root {
ue.command = cmd.Name()
}
return ue
}