init
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// optBool is the minimal stand-in for clicmd's optional flags: a flag.Value
|
||||
// that announces it takes no argument.
|
||||
type optBool struct{ v bool }
|
||||
|
||||
func (o *optBool) String() string { return "" }
|
||||
func (o *optBool) Set(string) error { o.v = true; return nil }
|
||||
func (o *optBool) IsBoolFlag() bool { return true }
|
||||
|
||||
func TestPermute(t *testing.T) {
|
||||
newFS := func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("name", "", "")
|
||||
fs.Bool("yes", false, "")
|
||||
fs.Var(&optBool{}, "spa", "")
|
||||
return fs
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
desc: "flags after the positional, which is what people type",
|
||||
in: []string{"demo", "--name", "Demo", "--spa"},
|
||||
want: []string{"--name", "Demo", "--spa", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "already in flag package order, unchanged apart from the separator",
|
||||
in: []string{"--name", "Demo", "demo"},
|
||||
want: []string{"--name", "Demo", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "a bool flag must not swallow the positional that follows it",
|
||||
in: []string{"--yes", "demo"},
|
||||
want: []string{"--yes", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "single-dash spelling is the same flag",
|
||||
in: []string{"demo", "-name", "Demo"},
|
||||
want: []string{"-name", "Demo", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "--flag=value carries its own argument",
|
||||
in: []string{"demo", "--name=Demo", "other"},
|
||||
want: []string{"--name=Demo", "--", "demo", "other"},
|
||||
},
|
||||
{
|
||||
desc: "a value that looks like a flag is still the flag's value",
|
||||
in: []string{"demo", "--name", "-weird"},
|
||||
want: []string{"--name", "-weird", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "everything after -- stays positional",
|
||||
in: []string{"--yes", "--", "--name", "demo"},
|
||||
want: []string{"--yes", "--", "--name", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "a lone dash is a positional, not a flag",
|
||||
in: []string{"-"},
|
||||
want: []string{"--", "-"},
|
||||
},
|
||||
{
|
||||
desc: "an unknown flag is passed through alone so flag.Parse reports it",
|
||||
in: []string{"--nope", "demo"},
|
||||
want: []string{"--nope", "--", "demo"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := permute(newFS(), tc.in)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("permute(%q)\n got %q\nwant %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermutedFlagsReachExec is the property the permutation exists for: the
|
||||
// command sees the same flags and the same positionals either way round.
|
||||
func TestPermutedFlagsReachExec(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"create", "demo", "--name", "Demo", "--spa"},
|
||||
{"create", "--name", "Demo", "--spa", "demo"},
|
||||
{"create", "--name", "Demo", "demo", "--spa"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var (
|
||||
name string
|
||||
spa optBool
|
||||
rest []string
|
||||
)
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "create",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&name, "name", "", "")
|
||||
fs.Var(&spa, "spa", "")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
rest = args
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := Run(context.Background(), root, args, io.Discard, nil); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if name != "Demo" || !spa.v {
|
||||
t.Errorf("flags: name=%q spa=%v, want Demo/true", name, spa.v)
|
||||
}
|
||||
if !reflect.DeepEqual(rest, []string{"demo"}) {
|
||||
t.Errorf("positionals: %q, want [demo]", rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentDoesNotPermute guards the other half of the rule: a parent must
|
||||
// leave a child's flags alone, or "pages project create --spa" would try to
|
||||
// parse --spa against the project command and fail.
|
||||
func TestParentDoesNotPermute(t *testing.T) {
|
||||
var spa bool
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "project",
|
||||
Sub: []*Command{{
|
||||
Name: "create",
|
||||
Flags: func(fs *flag.FlagSet) { fs.BoolVar(&spa, "spa", false, "") },
|
||||
Exec: func(context.Context, []string) error { return nil },
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if err := Run(context.Background(), root, []string{"project", "create", "--spa"}, io.Discard, nil); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !spa {
|
||||
t.Error("--spa did not reach the leaf command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageErrors(t *testing.T) {
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "leaf",
|
||||
Exec: func(context.Context, []string) error { return UsageErrorf("expected %d args", 1) },
|
||||
}},
|
||||
}
|
||||
|
||||
t.Run("unknown command", func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
err := Run(context.Background(), root, []string{"nope"}, &out, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `unknown command "nope"`) {
|
||||
t.Errorf("output did not name the unknown command:\n%s", out.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a parent with no child named is usage, not a crash", func(t *testing.T) {
|
||||
err := Run(context.Background(), root, nil, io.Discard, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UsageErrorf prints the command's usage and keeps its message", func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
err := Run(context.Background(), root, []string{"leaf"}, &out, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
if err.Error() != "expected 1 args" {
|
||||
t.Errorf("message = %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Usage:\n test leaf") {
|
||||
t.Errorf("usage text missing or misnamed:\n%s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseDuration accepts Go's duration syntax plus the day, week and year
|
||||
// suffixes an operator actually types for a key lifetime ("90d", "1y").
|
||||
//
|
||||
// A year is 365 days and a day is 24 hours: no calendar arithmetic, no time
|
||||
// zones, no leap seconds. The value becomes an absolute expiry timestamp on the
|
||||
// client precisely so a difference of a few hours cannot matter.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, errors.New("empty duration")
|
||||
}
|
||||
var mult time.Duration
|
||||
switch s[len(s)-1] {
|
||||
case 'd':
|
||||
mult = 24 * time.Hour
|
||||
case 'w':
|
||||
mult = 7 * 24 * time.Hour
|
||||
case 'y':
|
||||
mult = 365 * 24 * time.Hour
|
||||
default:
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
n, err := strconv.ParseFloat(s[:len(s)-1], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
|
||||
}
|
||||
return time.Duration(n * float64(mult)), nil
|
||||
}
|
||||
|
||||
// ParseBytes accepts a plain byte count or one with a unit suffix: "1048576",
|
||||
// "1MiB", "256M", "1.5G".
|
||||
//
|
||||
// K, M, G and T all mean the binary multiple, whether or not the suffix is
|
||||
// spelled with an "i". Disk-quota flags are set to round binary numbers, and a
|
||||
// tool that quietly read "256MB" as 256,000,000 would produce a limit its user
|
||||
// did not ask for.
|
||||
func ParseBytes(s string) (int64, error) {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return 0, errors.New("empty size")
|
||||
}
|
||||
digits := strings.TrimRight(t, "bBiIkKmMgGtT")
|
||||
// Uppercase before trimming so "MiB", "MIB" and "mib" all reduce to "M".
|
||||
unit := strings.ToUpper(t[len(digits):])
|
||||
unit = strings.TrimSuffix(unit, "B")
|
||||
unit = strings.TrimSuffix(unit, "I")
|
||||
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(digits), 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("invalid size %q: use a byte count or 256MiB, 2GiB", s)
|
||||
}
|
||||
var mult float64 = 1
|
||||
switch unit {
|
||||
case "":
|
||||
case "K":
|
||||
mult = 1 << 10
|
||||
case "M":
|
||||
mult = 1 << 20
|
||||
case "G":
|
||||
mult = 1 << 30
|
||||
case "T":
|
||||
mult = 1 << 40
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid size %q: unknown unit %q", s, unit)
|
||||
}
|
||||
return int64(n * mult), nil
|
||||
}
|
||||
|
||||
// KeyValue splits a "k=v" argument. The value may contain further '=' signs.
|
||||
func KeyValue(s string) (key, value string, err error) {
|
||||
k, v, ok := strings.Cut(s, "=")
|
||||
if !ok || k == "" {
|
||||
return "", "", fmt.Errorf("expected key=value, got %q", s)
|
||||
}
|
||||
return k, v, nil
|
||||
}
|
||||
|
||||
// ErrAborted is returned when the user declines a confirmation prompt.
|
||||
var ErrAborted = errors.New("aborted")
|
||||
|
||||
// Confirm asks for a yes before a destructive operation.
|
||||
//
|
||||
// It refuses rather than prompts when stdin is not a terminal: a pipeline that
|
||||
// blocks forever on a prompt nobody can see is worse than one that fails and
|
||||
// tells the operator to pass --yes.
|
||||
func Confirm(in io.Reader, out io.Writer, prompt string) error {
|
||||
if f, ok := in.(*os.File); ok && !isTerminal(f) {
|
||||
return fmt.Errorf("%w: refusing to prompt for confirmation with no terminal; pass --yes", ErrAborted)
|
||||
}
|
||||
fmt.Fprintf(out, "%s [y/N]: ", prompt)
|
||||
line, err := bufio.NewReader(in).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return ErrAborted
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return nil
|
||||
}
|
||||
return ErrAborted
|
||||
}
|
||||
|
||||
// isTerminal reports whether f is a character device. This is the cheap
|
||||
// stdlib-only approximation of a tty check; it is used to decide whether to
|
||||
// prompt and whether to draw progress, never for anything security-relevant.
|
||||
func isTerminal(f *os.File) bool {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// IsTerminal reports whether f is attached to a terminal.
|
||||
func IsTerminal(f *os.File) bool { return isTerminal(f) }
|
||||
@@ -0,0 +1,169 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The Opt* types are flag.Value implementations that remember whether they were
|
||||
// given on the command line.
|
||||
//
|
||||
// They exist for PATCH: api.ProjectPatch uses a pointer per field so the server
|
||||
// can tell "leave this alone" from "set it to the zero value". A plain
|
||||
// fs.StringVar cannot express that difference — an unset --not-found-file and
|
||||
// an explicit --not-found-file="" both arrive as "". Each type's Ptr method
|
||||
// produces exactly the pointer the patch field wants.
|
||||
|
||||
// OptString is an optional string flag.
|
||||
type OptString struct {
|
||||
Val string
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptString) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.Val
|
||||
}
|
||||
|
||||
func (o *OptString) Set(s string) error {
|
||||
o.Val, o.Present = s, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptString) Ptr() *string {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptBool is an optional boolean flag. It may be written as --flag as well as
|
||||
// --flag=false.
|
||||
type OptBool struct {
|
||||
Val bool
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptBool) String() string {
|
||||
if o == nil {
|
||||
return "false"
|
||||
}
|
||||
return strconv.FormatBool(o.Val)
|
||||
}
|
||||
|
||||
func (o *OptBool) Set(s string) error {
|
||||
v, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBoolFlag lets flag accept "--spa" without an explicit value.
|
||||
func (o *OptBool) IsBoolFlag() bool { return true }
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptBool) Ptr() *bool {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptInt is an optional integer flag.
|
||||
type OptInt struct {
|
||||
Val int
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptInt) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(o.Val)
|
||||
}
|
||||
|
||||
func (o *OptInt) Set(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptInt) Ptr() *int {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptBytes is an optional byte count, written as a plain number or with a unit
|
||||
// suffix ("256MiB").
|
||||
type OptBytes struct {
|
||||
Val int64
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptBytes) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(o.Val, 10)
|
||||
}
|
||||
|
||||
func (o *OptBytes) Set(s string) error {
|
||||
v, err := ParseBytes(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptBytes) Ptr() *int64 {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptDuration is an optional duration, accepting the same suffixes as
|
||||
// ParseDuration.
|
||||
type OptDuration struct {
|
||||
Val time.Duration
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptDuration) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.Val.String()
|
||||
}
|
||||
|
||||
func (o *OptDuration) Set(s string) error {
|
||||
v, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecondsPtr returns the duration in whole seconds, or nil if the flag was not
|
||||
// given. The API expresses grace periods as an integer number of seconds.
|
||||
func (o *OptDuration) SecondsPtr() *int {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
s := int(o.Val / time.Second)
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Output formats.
|
||||
const (
|
||||
FormatTable = "table"
|
||||
FormatJSON = "json"
|
||||
)
|
||||
|
||||
// ValidFormat reports whether s names an output format.
|
||||
func ValidFormat(s string) bool { return s == FormatTable || s == FormatJSON }
|
||||
|
||||
// Printer writes command output in the format the user asked for.
|
||||
type Printer struct {
|
||||
Out io.Writer
|
||||
Format string
|
||||
}
|
||||
|
||||
// Print renders v. In table format it calls table to build the rendering; a nil
|
||||
// table means the value has no tabular form and JSON is used regardless.
|
||||
func (p *Printer) Print(v any, table func() *Table) error {
|
||||
if p.Format == FormatJSON || table == nil {
|
||||
return p.JSON(v)
|
||||
}
|
||||
return table().Write(p.Out)
|
||||
}
|
||||
|
||||
// JSON writes v as indented JSON with a trailing newline.
|
||||
func (p *Printer) JSON(v any) error {
|
||||
enc := json.NewEncoder(p.Out)
|
||||
enc.SetIndent("", " ")
|
||||
// The output is read by humans and by jq, neither of which wants &, < and >
|
||||
// spelled as & and friends.
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// Printf writes a human-readable line, and nothing at all in JSON format —
|
||||
// progress chatter must never end up in a stream something is parsing.
|
||||
func (p *Printer) Printf(format string, args ...any) {
|
||||
if p.Format == FormatJSON {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(p.Out, format, args...)
|
||||
}
|
||||
|
||||
// Table is a column-aligned rendering built up row by row.
|
||||
type Table struct {
|
||||
header []string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
// NewTable starts a table with the given column headings.
|
||||
func NewTable(header ...string) *Table { return &Table{header: header} }
|
||||
|
||||
// Row appends a row. Cells beyond the header count are kept: a ragged table is
|
||||
// a formatting annoyance, not a reason to drop data.
|
||||
func (t *Table) Row(cells ...string) { t.rows = append(t.rows, cells) }
|
||||
|
||||
// Len reports how many rows have been added.
|
||||
func (t *Table) Len() int { return len(t.rows) }
|
||||
|
||||
// Write renders the table. An empty table prints its header only, so a caller
|
||||
// can tell "no rows" from "the command did nothing".
|
||||
func (t *Table) Write(w io.Writer) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
if len(t.header) > 0 {
|
||||
fmt.Fprintln(tw, strings.Join(t.header, "\t"))
|
||||
}
|
||||
for _, row := range t.rows {
|
||||
fmt.Fprintln(tw, strings.Join(row, "\t"))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ cell helpers
|
||||
|
||||
// Dash is what an empty cell shows, so a missing value is visibly missing
|
||||
// rather than looking like a column-alignment mistake.
|
||||
const Dash = "-"
|
||||
|
||||
// Str renders a string cell, showing Dash when empty.
|
||||
func Str(s string) string {
|
||||
if s == "" {
|
||||
return Dash
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Bool renders a boolean cell.
|
||||
func Bool(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// Plural renders a count with its noun, adding "s" for anything but one.
|
||||
// Only regular nouns are pluralized correctly, which is all this CLI has.
|
||||
func Plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return strconv.Itoa(n) + " " + noun + "s"
|
||||
}
|
||||
|
||||
// Time renders an absolute local timestamp to the second. Absolute rather than
|
||||
// relative: these values go into CI logs that are read days later, where "2
|
||||
// hours ago" has lost its reference point.
|
||||
func Time(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return Dash
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// TimePtr renders an optional timestamp.
|
||||
func TimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return Dash
|
||||
}
|
||||
return Time(*t)
|
||||
}
|
||||
|
||||
// Bytes renders a byte count in binary units, as a human reads it.
|
||||
func Bytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return strconv.FormatInt(n, 10) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n/div >= unit && exp < 4 {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// Truncate shortens s to at most max runes, marking the cut with an ellipsis so
|
||||
// a clipped value is never mistaken for a complete one.
|
||||
func Truncate(s string, max int) string {
|
||||
if max <= 1 {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user