package sink import ( "context" "flag" "fmt" "io" "os" "strings" "text/tabwriter" "github.com/peterbourgon/ff/v3" "edge-infra.dev/pkg/lib/cli/rags" ) var ( defaultOpts = []ff.Option{ff.WithEnvVarNoPrefix()} ) // Command is the basic building block for a CLI application. It can be // standalone or contain subcommands. type Command struct { // Use is the one-line usage information for this command. // // Recommended syntax: // ... indicates one or more values can be provided // [ ] optional arguments // < > required arguments // { } a set of mutually exclusive arguments // // It should _not_ include the parent's name as well. e.g., a Command.Use for // `lift pack` should be "pack". // // Example: cp [flags] ... Use string // Short is the one-line truncated help information, it should describe // what the command does. Multi-line help information should be set in // Command.Long Short string // Long is typically paragraph-or-more length content that provides more // detailed information about usage patterns and how to use arguments/flags. Long string // Flags are rich flags for this command. Flags []*rags.Rag // Exec is the function executed when the command is called. Exec func(ctx context.Context, r Run) error // Commands are any sub-commands this command may have. Commands []*Command // Extensions configures the additional functionality that this CLI will // handle during execution. Extensions are isolated to the selected command. Extensions []Extension // Options are ff.Options used when parsing bound flags. Options are inherited // from parents and merged. If none are provided, ff.WithEnvVarNoPrefix is // used. Options []ff.Option // UsageFn allows configuring the behavior of the default usage command // when printing usage. A default usage message is used if not provided. // Useful when you want to take control of the usage output without providing // a full Command implementation UsageFn func(*Command) string out io.Writer // Output stream for piping to other programs, defaults to os.Stdout err io.Writer // Log/progress output stream for humans, defaults to os.Stderr // Parse computes these fields rs *rags.RagSet // Actually bound flags, computed from Extensions + Flags selected *Command // The command to run based on flag arguments args []string // Args to be passed to Run computed bool // Whether the full command tree has been computed logLvl int // --log-level logJSON bool // --log-json help bool // --help parent *Command } // Extension represents the minimal contract for additional CLI functionality. // Extensions can implement additional interfaces, like [BeforeRunner], to hook // into the CLI lifecycle in order to implement further functionality. type Extension interface { RegisterFlags(rs *rags.RagSet) } // BeforeRunner is an extension that is executed after flags are parsed and // before the Command is executed, e.g., to instantiate a client or load config. // // Changes to the input Context and Run are expected to be returned. type BeforeRunner interface { BeforeRun(context.Context, Run) (context.Context, Run, error) } // AfterRunner is an extension that is executed after the Command has completed. // // Changes to the input Context and Run are expected to be returned. type AfterRunner interface { AfterRun(context.Context, Run) (context.Context, Run, error) } // Name returns the name of the current command, without parents. // e.g., `cli group subcommand` -> `subcommand` func (c *Command) Name() string { n := c.Use if i := strings.Index(n, " "); i >= 0 { n = n[:i] } return n } // LongName returns the full name of the current command, including parents, if // any exist. func (c *Command) LongName() string { if c.HasParent() { return c.Parent().LongName() + " " + c.Name() } return c.Name() } // HasParent returns true if this command is registered as a subcommand func (c *Command) HasParent() bool { return c.parent != nil } // Parent returns this Command's ancestor, if it exists. func (c *Command) Parent() *Command { return c.parent } // AllParsingOptions returns the cumulative set of ff.Options from c and any // parents that exist. func (c *Command) AllParsingOptions() []ff.Option { if c.HasParent() { return append(c.Parent().AllParsingOptions(), c.Options...) } return c.Options } // Usage returns the full usage information string for this command. func (c *Command) Usage() string { if c.UsageFn != nil { return c.UsageFn(c) } return defaultUsageFn(c) } // Parse evaluates c, binding and parsing flags and determining which command // to execute based on input. After a successful [Command.Parse], [Command.Run] // can be called. func (c *Command) Parse(args []string) error { if c.selected != nil { return nil } if err := c.compute(); err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } parsingOpts := c.AllParsingOptions() if len(parsingOpts) == 0 { parsingOpts = defaultOpts } if err := ff.Parse(c.rs.FlagSet(), args, parsingOpts...); err != nil { return fmt.Errorf("failed to parse options: %w", err) } c.args = c.rs.FlagSet().Args() if len(c.args) > 0 { for _, scmd := range c.Commands { if strings.EqualFold(c.args[0], scmd.Name()) { c.selected = scmd return scmd.Parse(c.args[1:]) } } } c.selected = c return nil } // compute initializes the full command tree from c func (c *Command) compute() error { if c.computed { return nil } for i := range c.Commands { // Wire up parent relationships c.Commands[i].parent = c if c.Commands[i] == c { return fmt.Errorf("command %s cannot be child of itself", c.Name()) } for x := range c.Commands { if x != i && c.Commands[i].Name() == c.Commands[x].Name() { return fmt.Errorf("command %s defined twice", c.Commands[i].Name()) } } } // Initialize FlagSet and bind flags if c.rs == nil { c.rs = rags.New(c.Name(), flag.ContinueOnError, c.globalFlags()...) } c.rs.Add(c.Flags...) for _, e := range c.Extensions { e.RegisterFlags(c.rs) } // If Exec is nil (e.g., if its a root command), set to default usage info if c.Exec == nil { c.Exec = usageCmd } for i := range c.Commands { if err := c.Commands[i].compute(); err != nil { return fmt.Errorf("command '%s' is invalid: %w", c.Commands[i].Name(), err) } } c.computed = true return nil } // Run executes the Command after a successful call to [Command.Parse]. func (c *Command) Run(ctx context.Context) error { switch { case c.selected == nil: return fmt.Errorf("Run() called without calling Parse()") case c.selected == c: return c.execute(ctx, newRun(c)) default: return c.selected.Run(ctx) } } func (c *Command) execute(ctx context.Context, r Run) (err error) { defer func() { // TODO(aw185176): improve clog handling of error logs w/o msg so that we can // rely on error and not add static noise if err != nil { r.Log.Error(err, "command failed") } }() if ctx, r, err = c.beforeRun(ctx, r); err != nil { return err } if c.help { return usageCmd(ctx, r) } if err = c.Exec(ctx, r); err != nil { return } _, r, err = c.afterRun(ctx, r) return } func (c *Command) beforeRun(ctx context.Context, r Run) (context.Context, Run, error) { for _, e := range c.Extensions { if b, ok := e.(BeforeRunner); ok { var err error ctx, r, err = b.BeforeRun(ctx, r) if err != nil { return ctx, r, err } } } return ctx, r, nil } func (c *Command) afterRun(ctx context.Context, r Run) (context.Context, Run, error) { for _, e := range c.Extensions { if b, ok := e.(AfterRunner); ok { var err error ctx, r, err = b.AfterRun(ctx, r) if err != nil { return ctx, r, err } } } return ctx, r, nil } // ParseAndRun is a helper that invokes Parse() and Run() in a single // invocation. func (c *Command) ParseAndRun(ctx context.Context, args []string) error { if err := c.Parse(args); err != nil { fmt.Fprintln(c.getErr(), err) return err } return c.Run(ctx) } func (c *Command) globalFlags() []*rags.Rag { return []*rags.Rag{ { Name: "help", Short: "h", Usage: "Display help information", Value: &rags.Bool{Var: &c.help}, Category: rags.Global, }, { Name: "log-level", Short: "v", Usage: "Control logging verbosity. A higher number means chattier logs", Value: &rags.Int{Var: &c.logLvl}, Category: rags.Global, }, { Name: "log-json", Usage: "Emit JSON logs", Value: &rags.Bool{Var: &c.logJSON}, Category: rags.Global, }, } } // SetOut configures the standard output stream for c. Defaults to os.Stdout func (c *Command) SetOut(w io.Writer) { c.out = w } // SetErr configures the error output stream for c, used for error output, usage // information, and any CLI logs. Defaults to os.Stderr func (c *Command) SetErr(w io.Writer) { c.err = w } func (c *Command) getOut() io.Writer { switch { case c.out != nil: return c.out case c.HasParent() && c.Parent().out != nil: return c.Parent().out default: return os.Stdout } } func (c *Command) getErr() io.Writer { switch { case c.err != nil: return c.err case c.HasParent() && c.Parent().err != nil: return c.Parent().err default: return os.Stderr } } // usageCmd is invoked if // // - no other Exec is provided // - invalid flags/args are provided // - --help is provided func usageCmd(_ context.Context, r Run) error { fmt.Fprintln(r.Err(), r.Cmd().Usage()) return nil } // defaultUsageFn is used if [Command.UsageFn] is not set func defaultUsageFn(c *Command) string { var b strings.Builder // Print either the long or short help above the usage information switch { case c.Long != "": fmt.Fprintln(&b, c.Long) fmt.Fprintln(&b) case c.Short != "": fmt.Fprintln(&b, c.Short) fmt.Fprintln(&b) } fmt.Fprintln(&b, "Usage:") tw := tabwriter.NewWriter(&b, 2, 0, 2, ' ', 0) defer tw.Flush() fmt.Fprintf(tw, "\t%s\t\t\n\n", useline(c)) if len(c.Commands) > 0 { fmt.Fprintln(tw, "Commands:") for _, subcommand := range c.Commands { fmt.Fprintf(tw, "\t%s\t%s\t\n", subcommand.Name(), subcommand.Short) } fmt.Fprintln(tw) } if c.rs != nil && len(c.rs.Rags()) > 0 { c.rs.SetOutput(&b) c.rs.Usage() } return b.String() } // useline returns the single line usage summary, including any parents that exist. func useline(c *Command) string { if c.HasParent() { return c.Parent().LongName() + " " + c.Use } return c.Use }