Files
pages/internal/cliutil/command.go
T
2026-08-15 07:13:00 +00:00

238 lines
7.5 KiB
Go

// Package cliutil is the CLI's plumbing: a small subcommand tree over the
// standard flag package, and table/JSON rendering.
//
// It is deliberately not a CLI framework, but not for the reason usually
// given. Measured on this machine, net/http and crypto/tls alone put the floor
// for any Go HTTP client at 5.4 MB stripped; the pages binary is 6.0 MB, and
// the same program written with cobra came out at 5.9 MB. Half a megabyte
// either way is noise next to the TLS stack, so "cobra is too big" would be a
// claim the numbers do not support.
//
// The actual reason is dependency surface. This tool has a fixed set of about
// a dozen commands whose flags are plain strings, bools and ints; it needs no
// shell completion, no generated man pages, no dynamic command registration.
// The tree below is the part of a framework it would use, and it is small
// enough to read in one sitting — which matters more for a binary that CI jobs
// download and run with a deployment credential in the environment.
package cliutil
import (
"context"
"errors"
"flag"
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
)
// ErrUsage is returned when the command line does not name something runnable.
// The caller prints the usage text that accompanies it and exits non-zero.
var ErrUsage = errors.New("usage")
// Command is one node of the command tree. A node either runs (Exec) or has
// children (Sub), never both.
type Command struct {
// Name is the single word that selects this command.
Name string
// Args describes the positional arguments for the usage line, e.g. "<name>".
Args string
// Short is the one-line summary listed by the parent.
Short string
// Long is optional additional prose printed by --help.
Long string
// Flags registers this command's flags. It runs once per invocation, before
// parsing, so the variables it binds are the ones Exec reads.
Flags func(fs *flag.FlagSet)
// Exec runs the command with the positional arguments left after parsing.
Exec func(ctx context.Context, args []string) error
// Sub are the child commands, if any.
Sub []*Command
// parent is filled in during dispatch so usage text can print the full path.
parent *Command
}
// Persistent registers flags that every command in the tree accepts.
//
// The values it binds must already hold whatever an outer parse produced, and
// it must register them with those values as the defaults — see Globals.Register
// in internal/clicmd. Registering with a fixed default instead would reset a
// flag given before the subcommand name ("pages --server X project list"),
// because flag.StringVar assigns the default at registration time.
type Persistent func(fs *flag.FlagSet)
// Run parses args against c and dispatches. out receives usage and help text.
func Run(ctx context.Context, c *Command, args []string, out io.Writer, persistent Persistent) error {
fs := flag.NewFlagSet(c.path(), flag.ContinueOnError)
fs.SetOutput(out)
fs.Usage = func() { c.printUsage(out, fs) }
if persistent != nil {
persistent(fs)
}
if c.Flags != nil {
c.Flags(fs)
}
if len(c.Sub) == 0 {
args = permute(fs, args)
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// flag has already printed the usage via fs.Usage.
return nil
}
// flag printed the message; adding our own would double it.
return ErrUsage
}
rest := fs.Args()
if len(c.Sub) > 0 && len(rest) > 0 {
for _, sub := range c.Sub {
if sub.Name == rest[0] {
sub.parent = c
return Run(ctx, sub, rest[1:], out, persistent)
}
}
fmt.Fprintf(out, "unknown command %q\n\n", rest[0])
c.printUsage(out, fs)
return ErrUsage
}
// A leaf, or a parent invoked without naming a child. The latter still gets
// its Exec so the root can answer --version before falling back to usage.
if c.Exec == nil {
c.printUsage(out, fs)
return ErrUsage
}
err := c.Exec(ctx, rest)
if errors.Is(err, ErrUsage) {
c.printUsage(out, fs)
}
return err
}
// permute moves flags ahead of positional arguments, so that
//
// pages project create demo --spa
//
// works as well as the order the flag package wants:
//
// pages project create --spa demo
//
// Stopping at the first non-flag argument is right for a command that has
// children — the first positional there names the child, and its flags belong
// to it, not to us. A leaf has no such ambiguity, so it gets the permuting
// parse people expect from every other CLI they use.
//
// Two tokens are left exactly where they are: "--" terminates flags for good,
// and an unrecognised flag is passed through alone so that flag.Parse reports
// it rather than this function silently swallowing the argument after it.
func permute(fs *flag.FlagSet, args []string) []string {
known := map[string]*flag.Flag{}
fs.VisitAll(func(f *flag.Flag) { known[f.Name] = f })
var flags, rest []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
rest = append(rest, args[i+1:]...)
break
}
// "-" alone is the conventional name for stdin, not a flag.
if len(a) < 2 || a[0] != '-' {
rest = append(rest, a)
continue
}
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if strings.ContainsRune(name, '=') {
continue // --name=value carries its own argument
}
// A non-boolean flag takes the next token with it. Booleans must not
// swallow anything: "--spa demo" is a flag and a positional.
if f, ok := known[name]; ok && !isBoolFlag(f) && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
// The separator makes the tail positional even if a value there looks like
// a flag, which matters for paths and metadata values.
return append(flags, append([]string{"--"}, rest...)...)
}
func isBoolFlag(f *flag.Flag) bool {
b, ok := f.Value.(interface{ IsBoolFlag() bool })
return ok && b.IsBoolFlag()
}
// UsageErrorf returns an error that makes Run print the command's usage after
// the caller reports the message. Use it for "wrong number of arguments" and
// friends, where the fix is visible in the usage text.
func UsageErrorf(format string, args ...any) error {
return &usageError{msg: fmt.Sprintf(format, args...)}
}
type usageError struct{ msg string }
func (e *usageError) Error() string { return e.msg }
// Is makes errors.Is(err, ErrUsage) true for any usageError.
func (e *usageError) Is(target error) bool { return target == ErrUsage }
// path is the space-separated command path, used in usage text and as the
// FlagSet name so flag's own error messages name the right command.
func (c *Command) path() string {
if c.parent == nil {
return c.Name
}
return c.parent.path() + " " + c.Name
}
func (c *Command) printUsage(out io.Writer, fs *flag.FlagSet) {
if c.Short != "" {
fmt.Fprintf(out, "%s — %s\n\n", c.path(), c.Short)
}
fmt.Fprintf(out, "Usage:\n %s", c.path())
if len(c.Sub) > 0 {
fmt.Fprint(out, " <command>")
}
if hasFlags(fs) {
fmt.Fprint(out, " [flags]")
}
if c.Args != "" {
fmt.Fprintf(out, " %s", c.Args)
}
fmt.Fprint(out, "\n")
if len(c.Sub) > 0 {
fmt.Fprint(out, "\nCommands:\n")
tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
subs := append([]*Command(nil), c.Sub...)
sort.Slice(subs, func(i, j int) bool { return subs[i].Name < subs[j].Name })
for _, sub := range subs {
fmt.Fprintf(tw, " %s\t%s\n", sub.Name, sub.Short)
}
tw.Flush()
}
if hasFlags(fs) {
fmt.Fprint(out, "\nFlags:\n")
fs.PrintDefaults()
}
if c.Long != "" {
fmt.Fprintf(out, "\n%s\n", strings.TrimSpace(c.Long))
}
}
func hasFlags(fs *flag.FlagSet) bool {
n := 0
fs.VisitAll(func(*flag.Flag) { n++ })
return n > 0
}