- 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
217 lines
6.1 KiB
Go
217 lines
6.1 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(_ *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) {
|
|
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 Usage(fn(cmd, args))
|
|
}
|
|
}
|