From dd9fe8ca03dc108f9c157bc3f1b43ee4b54986a1 Mon Sep 17 00:00:00 2001 From: RogerCecilpot Date: Sun, 25 Oct 2026 12:24:02 +0000 Subject: [PATCH] feat: output formats, JSON errors, exit codes and non-interactive behaviour - --format table|tsv|json|yaml: a table at a terminal, header-less TSV when piped; JSON/YAML print the API resource shape. --jq filters JSON with gojq (strings print raw), --quiet prints IDs only. - Colour only on a terminal, off with NO_COLOR, --no-color or TERM=dumb. - Errors are printed and mapped to exit codes in one place (cli.Execute). Under --format json or --jq, stderr carries one {code,message,http_status,request_id,details,retryable} object. - Exit codes 0-8 and 130 per the design spec; Cobra's own errors (unknown command, bad flag, wrong args) are usage errors (exit 2). - prompt.Confirm never prompts without a TTY and asks for --yes instead. - --verbosity / DEVLYFT_DEBUG=1 logging through a redacting slog handler; redact masks bearer tokens, JWTs, sensitive JSON fields, query/form parameters and sensitive headers. - Golden-file tests for every format and exit code. Refs devlyft/devlyft#246 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + cmd/devlyft/main.go | 9 +- go.mod | 12 +- go.sum | 16 ++ internal/cli/root.go | 147 +++++++++- internal/cli/root_test.go | 256 +++++++++++++++-- internal/cli/testdata/confirm_no_tty.golden | 6 + internal/cli/testdata/confirm_tty_no.golden | 5 + internal/cli/testdata/confirm_tty_yes.golden | 6 + internal/cli/testdata/confirm_yes_flag.golden | 5 + internal/cli/testdata/describe_default.golden | 8 + internal/cli/testdata/describe_json.golden | 11 + .../cli/testdata/err_auth_required.golden | 6 + .../testdata/err_auth_required_json.golden | 5 + internal/cli/testdata/err_conflict.golden | 6 + internal/cli/testdata/err_deadline.golden | 5 + internal/cli/testdata/err_internal.golden | 6 + .../cli/testdata/err_invalid_argument.golden | 6 + internal/cli/testdata/err_not_found.golden | 6 + .../cli/testdata/err_permission_denied.golden | 6 + .../cli/testdata/err_placement_pending.golden | 6 + internal/cli/testdata/err_plain.golden | 5 + .../cli/testdata/err_rollout_failed.golden | 6 + internal/cli/testdata/err_timeout.golden | 6 + internal/cli/testdata/err_unavailable.golden | 6 + internal/cli/testdata/http_401.golden | 5 + internal/cli/testdata/http_403_json.golden | 5 + internal/cli/testdata/http_404.golden | 6 + internal/cli/testdata/http_409.golden | 5 + internal/cli/testdata/http_412.golden | 5 + internal/cli/testdata/http_422.golden | 5 + internal/cli/testdata/http_429_json.golden | 5 + internal/cli/testdata/http_503.golden | 5 + internal/cli/testdata/interrupted.golden | 5 + internal/cli/testdata/interrupted_json.golden | 5 + .../cli/testdata/list_default_pipe.golden | 6 + internal/cli/testdata/list_default_tty.golden | 7 + .../testdata/list_default_tty_no_color.golden | 7 + internal/cli/testdata/list_jq_objects.golden | 6 + internal/cli/testdata/list_jq_strings.golden | 6 + internal/cli/testdata/list_json.golden | 17 ++ internal/cli/testdata/list_quiet.golden | 6 + internal/cli/testdata/list_table.golden | 7 + internal/cli/testdata/list_tsv.golden | 6 + internal/cli/testdata/list_yaml.golden | 10 + internal/cli/testdata/usage_bad_format.golden | 6 + internal/cli/testdata/usage_bad_jq.golden | 5 + .../cli/testdata/usage_bad_verbosity.golden | 6 + internal/cli/testdata/usage_extra_args.golden | 6 + .../cli/testdata/usage_jq_with_yaml.golden | 5 + .../cli/testdata/usage_quiet_with_json.golden | 5 + .../cli/testdata/usage_unknown_command.golden | 6 + .../cli/testdata/usage_unknown_flag.golden | 6 + .../testdata/usage_unknown_flag_json.golden | 5 + internal/clierr/clierr.go | 153 ++++++++++ internal/clierr/clierr_test.go | 88 ++++++ internal/clierr/print.go | 58 ++++ internal/cmdutil/factory.go | 21 ++ internal/cmdutil/log.go | 75 +++++ internal/cmdutil/log_test.go | 61 ++++ internal/exitcode/exitcode.go | 20 +- internal/iostreams/iostreams.go | 78 +++++ internal/output/doc.go | 3 - internal/output/output.go | 266 ++++++++++++++++++ internal/output/output_test.go | 46 +++ internal/prompt/prompt.go | 37 +++ internal/redact/redact.go | 75 +++++ internal/redact/redact_test.go | 70 +++++ 68 files changed, 1751 insertions(+), 38 deletions(-) create mode 100644 .gitattributes create mode 100644 internal/cli/testdata/confirm_no_tty.golden create mode 100644 internal/cli/testdata/confirm_tty_no.golden create mode 100644 internal/cli/testdata/confirm_tty_yes.golden create mode 100644 internal/cli/testdata/confirm_yes_flag.golden create mode 100644 internal/cli/testdata/describe_default.golden create mode 100644 internal/cli/testdata/describe_json.golden create mode 100644 internal/cli/testdata/err_auth_required.golden create mode 100644 internal/cli/testdata/err_auth_required_json.golden create mode 100644 internal/cli/testdata/err_conflict.golden create mode 100644 internal/cli/testdata/err_deadline.golden create mode 100644 internal/cli/testdata/err_internal.golden create mode 100644 internal/cli/testdata/err_invalid_argument.golden create mode 100644 internal/cli/testdata/err_not_found.golden create mode 100644 internal/cli/testdata/err_permission_denied.golden create mode 100644 internal/cli/testdata/err_placement_pending.golden create mode 100644 internal/cli/testdata/err_plain.golden create mode 100644 internal/cli/testdata/err_rollout_failed.golden create mode 100644 internal/cli/testdata/err_timeout.golden create mode 100644 internal/cli/testdata/err_unavailable.golden create mode 100644 internal/cli/testdata/http_401.golden create mode 100644 internal/cli/testdata/http_403_json.golden create mode 100644 internal/cli/testdata/http_404.golden create mode 100644 internal/cli/testdata/http_409.golden create mode 100644 internal/cli/testdata/http_412.golden create mode 100644 internal/cli/testdata/http_422.golden create mode 100644 internal/cli/testdata/http_429_json.golden create mode 100644 internal/cli/testdata/http_503.golden create mode 100644 internal/cli/testdata/interrupted.golden create mode 100644 internal/cli/testdata/interrupted_json.golden create mode 100644 internal/cli/testdata/list_default_pipe.golden create mode 100644 internal/cli/testdata/list_default_tty.golden create mode 100644 internal/cli/testdata/list_default_tty_no_color.golden create mode 100644 internal/cli/testdata/list_jq_objects.golden create mode 100644 internal/cli/testdata/list_jq_strings.golden create mode 100644 internal/cli/testdata/list_json.golden create mode 100644 internal/cli/testdata/list_quiet.golden create mode 100644 internal/cli/testdata/list_table.golden create mode 100644 internal/cli/testdata/list_tsv.golden create mode 100644 internal/cli/testdata/list_yaml.golden create mode 100644 internal/cli/testdata/usage_bad_format.golden create mode 100644 internal/cli/testdata/usage_bad_jq.golden create mode 100644 internal/cli/testdata/usage_bad_verbosity.golden create mode 100644 internal/cli/testdata/usage_extra_args.golden create mode 100644 internal/cli/testdata/usage_jq_with_yaml.golden create mode 100644 internal/cli/testdata/usage_quiet_with_json.golden create mode 100644 internal/cli/testdata/usage_unknown_command.golden create mode 100644 internal/cli/testdata/usage_unknown_flag.golden create mode 100644 internal/cli/testdata/usage_unknown_flag_json.golden create mode 100644 internal/clierr/clierr.go create mode 100644 internal/clierr/clierr_test.go create mode 100644 internal/clierr/print.go create mode 100644 internal/cmdutil/factory.go create mode 100644 internal/cmdutil/log.go create mode 100644 internal/cmdutil/log_test.go create mode 100644 internal/iostreams/iostreams.go delete mode 100644 internal/output/doc.go create mode 100644 internal/output/output.go create mode 100644 internal/output/output_test.go create mode 100644 internal/prompt/prompt.go create mode 100644 internal/redact/redact.go create mode 100644 internal/redact/redact_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ea9608 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Check text files out with LF everywhere, including Windows, so golden +# files compare byte for byte with the CLI's output. +* text=auto eol=lf diff --git a/cmd/devlyft/main.go b/cmd/devlyft/main.go index 7b404c8..a0ab4a3 100644 --- a/cmd/devlyft/main.go +++ b/cmd/devlyft/main.go @@ -7,11 +7,18 @@ import ( "os/signal" "github.com/devlyft/cli/internal/cli" + "github.com/devlyft/cli/internal/iostreams" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - code := cli.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr) + go func() { + // After the first Ctrl-C, restore the default handler so a second + // one terminates immediately. + <-ctx.Done() + stop() + }() + code := cli.Execute(ctx, iostreams.System(), os.Args[1:]) stop() os.Exit(code) } diff --git a/go.mod b/go.mod index 625b101..81d8b3d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,18 @@ module github.com/devlyft/cli -go 1.26 +go 1.26.0 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/itchyny/gojq v0.12.19 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.47.0 + sigs.k8s.io/yaml v1.6.0 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.49.0 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..d7f84b4 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,26 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cli/root.go b/internal/cli/root.go index 5c6e605..3b19460 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,39 +5,164 @@ package cli import ( "context" + "errors" "fmt" - "io" + "os" + "strings" "github.com/spf13/cobra" + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/cmdutil" "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/iostreams" + "github.com/devlyft/cli/internal/output" ) +// globalFlags are the persistent flags every command accepts. +type globalFlags struct { + format string + jq string + quiet bool + noColor bool + verbosity string +} + // NewRootCmd returns the root `devlyft` command with all subcommands attached. -func NewRootCmd(stdout, stderr io.Writer) *cobra.Command { +// f is populated in the root's PersistentPreRunE, before any RunE. +func NewRootCmd(f *cmdutil.Factory) *cobra.Command { + var flags globalFlags + root := &cobra.Command{ Use: "devlyft", Short: "Deploy and operate apps on DevLyft", Long: "devlyft is the official command-line interface for DevLyft.", SilenceUsage: true, SilenceErrors: true, + PersistentPreRunE: func(*cobra.Command, []string) error { + return setup(f, &flags) + }, } - root.SetOut(stdout) - root.SetErr(stderr) + root.SetIn(f.IO.In) + root.SetOut(f.IO.Out) + root.SetErr(f.IO.ErrOut) + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return clierr.Usagef("%v", err) + }) + + pf := root.PersistentFlags() + pf.StringVar(&flags.format, "format", "", "Output format: table, tsv, json or yaml (default: table at a terminal, tsv otherwise)") + pf.StringVar(&flags.jq, "jq", "", "Filter JSON output with a jq expression") + pf.BoolVarP(&flags.quiet, "quiet", "q", false, "Print only resource IDs") + pf.BoolVar(&flags.noColor, "no-color", false, "Disable colour (also NO_COLOR)") + pf.StringVar(&flags.verbosity, "verbosity", "warning", "Log level for diagnostics on stderr: "+strings.Join(cmdutil.Verbosities, ", ")+" (DEVLYFT_DEBUG=1 for debug)") root.AddCommand(newVersionCmd()) return root } -// Execute runs the root command with args and returns the process exit code. -func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { - root := NewRootCmd(stdout, stderr) +// setup validates the global flags and builds the printer and logger. +func setup(f *cmdutil.Factory, flags *globalFlags) error { + format, err := output.ParseFormat(flags.format) + if err != nil { + return err + } + opts := output.Options{Format: format, JQ: flags.jq, Quiet: flags.quiet} + f.Printer = output.NewPrinter(f.IO, opts) + if err := opts.Validate(); err != nil { + return err + } + + if flags.noColor { + f.IO.DisableColor() + } + + verbosity := flags.verbosity + if os.Getenv("DEVLYFT_DEBUG") == "1" { + verbosity = "debug" + } + level, enabled, err := cmdutil.ParseVerbosity(verbosity) + if err != nil { + return err + } + f.Log = cmdutil.NewLogger(f.IO.ErrOut, level, enabled) + return nil +} + +// Execute runs the CLI with args and returns the process exit code. It is +// the only place errors are printed and mapped to exit codes. +func Execute(ctx context.Context, ios *iostreams.IOStreams, args []string) int { + f := &cmdutil.Factory{IO: ios} + return execute(ctx, f, NewRootCmd(f), args) +} + +func execute(ctx context.Context, f *cmdutil.Factory, root *cobra.Command, args []string) int { + ios := f.IO root.SetArgs(args) - if err := root.ExecuteContext(ctx); err != nil { - _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) - return exitcode.Error + // Errors returned before the root's PersistentPreRunE ran come from + // Cobra itself: unknown commands, bad flags and argument validation. + ranPreRun := false + preRun := root.PersistentPreRunE + root.PersistentPreRunE = func(cmd *cobra.Command, a []string) error { + ranPreRun = true + return preRun(cmd, a) + } + + err := root.ExecuteContext(ctx) + if err == nil { + return exitcode.OK + } + + if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) { + // Ctrl-C: no error object, even under --format json; the exit + // code says it all. + _, _ = fmt.Fprintln(ios.ErrOut, "Interrupted.") + return exitcode.Interrupted + } + + e := clierr.As(err) + if !ranPreRun && e.Code == clierr.CodeInternal { + e = &clierr.Error{Code: clierr.CodeInvalidArgument, Message: err.Error(), Usage: true, Err: err} + } + if e.Usage && e.Hint == "" { + e.Hint = fmt.Sprintf("Run '%s --help' for usage.", commandPath(root, args)) + } + + jsonErrors := wantsJSON(args) + if f.Printer != nil { + jsonErrors = f.Printer.Options().IsJSON() + } + if jsonErrors { + _ = clierr.WriteJSON(ios.ErrOut, e) + } else { + _ = clierr.WriteHuman(ios.ErrOut, e, ios.ErrColorEnabled()) + } + return e.ExitCode() +} + +// commandPath returns the deepest command args resolve to, for hints. +func commandPath(root *cobra.Command, args []string) string { + if cmd, _, err := root.Find(args); err == nil { + return cmd.CommandPath() + } + return root.CommandPath() +} + +// wantsJSON reports whether args ask for JSON output. It is used when an +// error happened before the flags could be validated, so that errors under +// --format json are still JSON. +func wantsJSON(args []string) bool { + for i, a := range args { + switch { + case a == "--": + return false + case a == "--format=json", strings.HasPrefix(a, "--jq="): + return true + case (a == "--format" && i+1 < len(args) && args[i+1] == "json") || a == "--jq": + return true + } } - return exitcode.OK + return false } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 64916e8..1cf59d5 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,43 +1,259 @@ package cli import ( - "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/devlyft/cli/internal/exitcode" + "github.com/spf13/cobra" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/cmdutil" + "github.com/devlyft/cli/internal/iostreams" + "github.com/devlyft/cli/internal/output" + "github.com/devlyft/cli/internal/prompt" ) +var update = flag.Bool("update", false, "rewrite golden files") + +type widget struct { + ID string `json:"id"` + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` +} + +var widgets = []widget{ + {ID: "0b1c", Name: "web", Labels: map[string]string{"tier": "frontend"}}, + {ID: "7f2e", Name: "worker\twith tab"}, +} + +// fixtureCommands are stand-ins for real commands, exercising the printer, +// the error mapping and confirmation. +func fixtureCommands(f *cmdutil.Factory) []*cobra.Command { + list := &cobra.Command{ + Use: "widgets", + RunE: func(*cobra.Command, []string) error { + t := &output.Table{Headers: []string{"id", "name"}} + ids := make([]string, 0, len(widgets)) + for _, w := range widgets { + t.Rows = append(t.Rows, []string{w.ID, w.Name}) + ids = append(ids, w.ID) + } + return f.Printer.Print(output.Result{Data: widgets, Table: t, IDs: ids}) + }, + } + describe := &cobra.Command{ + Use: "widget", + RunE: func(*cobra.Command, []string) error { + return f.Printer.Print(output.Result{Data: widgets[0], IDs: []string{widgets[0].ID}}) + }, + } + var status int + var code, requestID string + fail := &cobra.Command{ + Use: "fail", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + switch { + case status != 0: + return clierr.FromHTTP(status, "server said no", requestID) + case code == "plain": + return errors.New("dial tcp: connection refused") + case code == "deadline": + return fmt.Errorf("waiting: %w", context.DeadlineExceeded) + case code == "canceled": + return context.Canceled + } + e := clierr.New(clierr.Code(code), "failed with %s", code) + e.Hint = "try again" + e.Details = map[string]any{"deploy": "web"} + return e + }, + } + fail.Flags().IntVar(&status, "status", 0, "") + fail.Flags().StringVar(&code, "code", "", "") + fail.Flags().StringVar(&requestID, "request-id", "", "") + + var yes bool + del := &cobra.Command{ + Use: "delete", + RunE: func(cmd *cobra.Command, _ []string) error { + ok, err := prompt.Confirm(f.IO, "Delete widget web?", yes) + if err != nil { + return err + } + if ok { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "deleted") + } + return err + }, + } + del.Flags().BoolVar(&yes, "yes", false, cmdutil.YesFlagUsage) + + return []*cobra.Command{list, describe, fail, del} +} + +type golden struct { + name string + args []string + tty bool + stdin string + ctx func(context.Context) context.Context +} + +//nolint:paralleltest // uses t.Setenv +func TestGolden(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("DEVLYFT_DEBUG", "") + + cancelled := func(ctx context.Context) context.Context { + ctx, cancel := context.WithCancel(ctx) + cancel() + return ctx + } + + tests := []golden{ + // Formats. + {name: "list_default_pipe", args: []string{"widgets"}}, + {name: "list_default_tty", args: []string{"widgets"}, tty: true}, + {name: "list_default_tty_no_color", args: []string{"widgets", "--no-color"}, tty: true}, + {name: "list_table", args: []string{"widgets", "--format", "table"}}, + {name: "list_tsv", args: []string{"widgets", "--format=tsv"}, tty: true}, + {name: "list_json", args: []string{"widgets", "--format", "json"}}, + {name: "list_yaml", args: []string{"widgets", "--format", "yaml"}}, + {name: "list_jq_strings", args: []string{"widgets", "--jq", ".[].name"}}, + {name: "list_jq_objects", args: []string{"widgets", "--format", "json", "--jq", ".[] | {id}"}}, + {name: "list_quiet", args: []string{"widgets", "-q"}}, + {name: "describe_default", args: []string{"widget"}, tty: true}, + {name: "describe_json", args: []string{"widget", "--format", "json"}}, + + // Usage errors: exit 2. + {name: "usage_unknown_command", args: []string{"nope"}}, + {name: "usage_unknown_flag", args: []string{"widgets", "--bogus"}}, + {name: "usage_unknown_flag_json", args: []string{"widgets", "--bogus", "--format", "json"}}, + {name: "usage_extra_args", args: []string{"fail", "extra"}}, + {name: "usage_bad_format", args: []string{"widgets", "--format", "xml"}}, + {name: "usage_jq_with_yaml", args: []string{"widgets", "--format", "yaml", "--jq", "."}}, + {name: "usage_bad_jq", args: []string{"widgets", "--jq", ".["}}, + {name: "usage_quiet_with_json", args: []string{"widgets", "-q", "--format", "json"}}, + {name: "usage_bad_verbosity", args: []string{"widgets", "--verbosity", "loud"}}, + + // Each error code, human and JSON. + {name: "err_auth_required", args: []string{"fail", "--code", "auth_required"}}, + {name: "err_auth_required_json", args: []string{"fail", "--code", "auth_required", "--format", "json"}}, + {name: "err_permission_denied", args: []string{"fail", "--code", "permission_denied"}}, + {name: "err_not_found", args: []string{"fail", "--code", "not_found"}}, + {name: "err_conflict", args: []string{"fail", "--code", "conflict"}}, + {name: "err_invalid_argument", args: []string{"fail", "--code", "invalid_argument"}}, + {name: "err_timeout", args: []string{"fail", "--code", "timeout"}}, + {name: "err_placement_pending", args: []string{"fail", "--code", "placement_pending"}}, + {name: "err_rollout_failed", args: []string{"fail", "--code", "rollout_failed"}}, + {name: "err_unavailable", args: []string{"fail", "--code", "unavailable"}}, + {name: "err_internal", args: []string{"fail", "--code", "internal"}}, + {name: "err_plain", args: []string{"fail", "--code", "plain"}}, + {name: "err_deadline", args: []string{"fail", "--code", "deadline"}}, + + // API errors. + {name: "http_401", args: []string{"fail", "--status", "401"}}, + {name: "http_403_json", args: []string{"fail", "--status", "403", "--request-id", "req-123", "--jq", "."}}, + {name: "http_404", args: []string{"fail", "--status", "404", "--request-id", "req-123"}}, + {name: "http_409", args: []string{"fail", "--status", "409"}}, + {name: "http_412", args: []string{"fail", "--status", "412"}}, + {name: "http_422", args: []string{"fail", "--status", "422"}}, + {name: "http_429_json", args: []string{"fail", "--status", "429", "--format", "json"}}, + {name: "http_503", args: []string{"fail", "--status", "503"}}, + + // Interrupt: exit 130. + {name: "interrupted", args: []string{"fail", "--code", "canceled"}, ctx: cancelled}, + {name: "interrupted_json", args: []string{"fail", "--code", "canceled", "--format", "json"}, ctx: cancelled}, + + // Confirmation. + {name: "confirm_yes_flag", args: []string{"delete", "--yes"}}, + {name: "confirm_no_tty", args: []string{"delete"}}, + {name: "confirm_tty_yes", args: []string{"delete"}, tty: true, stdin: "y\n"}, + {name: "confirm_tty_no", args: []string{"delete"}, tty: true, stdin: "\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, in, stdout, stderr := iostreams.Test() + ios.SetTTY(tt.tty) + in.WriteString(tt.stdin) + + f := &cmdutil.Factory{IO: ios} + root := NewRootCmd(f) + root.AddCommand(fixtureCommands(f)...) + + ctx := t.Context() + if tt.ctx != nil { + ctx = tt.ctx(ctx) + } + code := execute(ctx, f, root, tt.args) + + got := fmt.Sprintf("$ devlyft %s\n--- exit %d\n--- stdout\n%s--- stderr\n%s", + strings.Join(tt.args, " "), code, stdout.String(), stderr.String()) + assertGolden(t, filepath.Join("testdata", tt.name+".golden"), got) + }) + } +} + +func assertGolden(t *testing.T, path, got string) { + t.Helper() + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o600); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) //nolint:gosec // test fixture path + if err != nil { + t.Fatalf("reading golden file (run with -update to create): %v", err) + } + if got != string(want) { + t.Errorf("output mismatch for %s (run with -update to accept)\n--- got\n%s\n--- want\n%s", path, got, want) + } +} + func TestExecuteVersion(t *testing.T) { t.Parallel() - var stdout, stderr bytes.Buffer - code := Execute(t.Context(), []string{"version"}, &stdout, &stderr) + ios, _, stdout, stderr := iostreams.Test() + code := Execute(t.Context(), ios, []string{"version"}) - if code != exitcode.OK { - t.Fatalf("exit code = %d, want %d (stderr: %q)", code, exitcode.OK, stderr.String()) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr.String()) } if !strings.HasPrefix(stdout.String(), "devlyft ") { t.Errorf("stdout = %q, want prefix %q", stdout.String(), "devlyft ") } - if stderr.Len() != 0 { - t.Errorf("stderr = %q, want empty", stderr.String()) - } } -func TestExecuteUnknownCommand(t *testing.T) { +func TestWantsJSON(t *testing.T) { t.Parallel() - var stdout, stderr bytes.Buffer - code := Execute(t.Context(), []string{"nope"}, &stdout, &stderr) - - if code != exitcode.Error { - t.Fatalf("exit code = %d, want %d", code, exitcode.Error) - } - if !strings.Contains(stderr.String(), `unknown command "nope"`) { - t.Errorf("stderr = %q, want unknown command error", stderr.String()) + tests := []struct { + args []string + want bool + }{ + {[]string{"x", "--format", "json"}, true}, + {[]string{"x", "--format=json"}, true}, + {[]string{"x", "--jq", "."}, true}, + {[]string{"x", "--jq=."}, true}, + {[]string{"x", "--format", "yaml"}, false}, + {[]string{"x", "--", "--format", "json"}, false}, + {[]string{"x"}, false}, } - if stdout.Len() != 0 { - t.Errorf("stdout = %q, want empty", stdout.String()) + for _, tt := range tests { + if got := wantsJSON(tt.args); got != tt.want { + t.Errorf("wantsJSON(%q) = %v, want %v", tt.args, got, tt.want) + } } } diff --git a/internal/cli/testdata/confirm_no_tty.golden b/internal/cli/testdata/confirm_no_tty.golden new file mode 100644 index 0000000..a72460b --- /dev/null +++ b/internal/cli/testdata/confirm_no_tty.golden @@ -0,0 +1,6 @@ +$ devlyft delete +--- exit 2 +--- stdout +--- stderr +Error: Delete widget web: pass --yes to confirm when not running interactively +Hint: Run 'devlyft delete --help' for usage. diff --git a/internal/cli/testdata/confirm_tty_no.golden b/internal/cli/testdata/confirm_tty_no.golden new file mode 100644 index 0000000..ed5de5d --- /dev/null +++ b/internal/cli/testdata/confirm_tty_no.golden @@ -0,0 +1,5 @@ +$ devlyft delete +--- exit 0 +--- stdout +--- stderr +Delete widget web? [y/N] \ No newline at end of file diff --git a/internal/cli/testdata/confirm_tty_yes.golden b/internal/cli/testdata/confirm_tty_yes.golden new file mode 100644 index 0000000..d764a58 --- /dev/null +++ b/internal/cli/testdata/confirm_tty_yes.golden @@ -0,0 +1,6 @@ +$ devlyft delete +--- exit 0 +--- stdout +deleted +--- stderr +Delete widget web? [y/N] \ No newline at end of file diff --git a/internal/cli/testdata/confirm_yes_flag.golden b/internal/cli/testdata/confirm_yes_flag.golden new file mode 100644 index 0000000..85b38f6 --- /dev/null +++ b/internal/cli/testdata/confirm_yes_flag.golden @@ -0,0 +1,5 @@ +$ devlyft delete --yes +--- exit 0 +--- stdout +deleted +--- stderr diff --git a/internal/cli/testdata/describe_default.golden b/internal/cli/testdata/describe_default.golden new file mode 100644 index 0000000..99dddf9 --- /dev/null +++ b/internal/cli/testdata/describe_default.golden @@ -0,0 +1,8 @@ +$ devlyft widget +--- exit 0 +--- stdout +id: 0b1c +labels: + tier: frontend +name: web +--- stderr diff --git a/internal/cli/testdata/describe_json.golden b/internal/cli/testdata/describe_json.golden new file mode 100644 index 0000000..20b9911 --- /dev/null +++ b/internal/cli/testdata/describe_json.golden @@ -0,0 +1,11 @@ +$ devlyft widget --format json +--- exit 0 +--- stdout +{ + "id": "0b1c", + "name": "web", + "labels": { + "tier": "frontend" + } +} +--- stderr diff --git a/internal/cli/testdata/err_auth_required.golden b/internal/cli/testdata/err_auth_required.golden new file mode 100644 index 0000000..af4e365 --- /dev/null +++ b/internal/cli/testdata/err_auth_required.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code auth_required +--- exit 3 +--- stdout +--- stderr +Error: failed with auth_required +Hint: try again diff --git a/internal/cli/testdata/err_auth_required_json.golden b/internal/cli/testdata/err_auth_required_json.golden new file mode 100644 index 0000000..f387961 --- /dev/null +++ b/internal/cli/testdata/err_auth_required_json.golden @@ -0,0 +1,5 @@ +$ devlyft fail --code auth_required --format json +--- exit 3 +--- stdout +--- stderr +{"code":"auth_required","message":"failed with auth_required","http_status":null,"request_id":null,"details":{"deploy":"web"},"retryable":false} diff --git a/internal/cli/testdata/err_conflict.golden b/internal/cli/testdata/err_conflict.golden new file mode 100644 index 0000000..31faaed --- /dev/null +++ b/internal/cli/testdata/err_conflict.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code conflict +--- exit 5 +--- stdout +--- stderr +Error: failed with conflict +Hint: try again diff --git a/internal/cli/testdata/err_deadline.golden b/internal/cli/testdata/err_deadline.golden new file mode 100644 index 0000000..59d059d --- /dev/null +++ b/internal/cli/testdata/err_deadline.golden @@ -0,0 +1,5 @@ +$ devlyft fail --code deadline +--- exit 6 +--- stdout +--- stderr +Error: waiting: context deadline exceeded diff --git a/internal/cli/testdata/err_internal.golden b/internal/cli/testdata/err_internal.golden new file mode 100644 index 0000000..bdce26d --- /dev/null +++ b/internal/cli/testdata/err_internal.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code internal +--- exit 1 +--- stdout +--- stderr +Error: failed with internal +Hint: try again diff --git a/internal/cli/testdata/err_invalid_argument.golden b/internal/cli/testdata/err_invalid_argument.golden new file mode 100644 index 0000000..6fcc9a9 --- /dev/null +++ b/internal/cli/testdata/err_invalid_argument.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code invalid_argument +--- exit 1 +--- stdout +--- stderr +Error: failed with invalid_argument +Hint: try again diff --git a/internal/cli/testdata/err_not_found.golden b/internal/cli/testdata/err_not_found.golden new file mode 100644 index 0000000..9a2c9fd --- /dev/null +++ b/internal/cli/testdata/err_not_found.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code not_found +--- exit 4 +--- stdout +--- stderr +Error: failed with not_found +Hint: try again diff --git a/internal/cli/testdata/err_permission_denied.golden b/internal/cli/testdata/err_permission_denied.golden new file mode 100644 index 0000000..c38842e --- /dev/null +++ b/internal/cli/testdata/err_permission_denied.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code permission_denied +--- exit 3 +--- stdout +--- stderr +Error: failed with permission_denied +Hint: try again diff --git a/internal/cli/testdata/err_placement_pending.golden b/internal/cli/testdata/err_placement_pending.golden new file mode 100644 index 0000000..21b87d9 --- /dev/null +++ b/internal/cli/testdata/err_placement_pending.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code placement_pending +--- exit 7 +--- stdout +--- stderr +Error: failed with placement_pending +Hint: try again diff --git a/internal/cli/testdata/err_plain.golden b/internal/cli/testdata/err_plain.golden new file mode 100644 index 0000000..9e27a1e --- /dev/null +++ b/internal/cli/testdata/err_plain.golden @@ -0,0 +1,5 @@ +$ devlyft fail --code plain +--- exit 1 +--- stdout +--- stderr +Error: dial tcp: connection refused diff --git a/internal/cli/testdata/err_rollout_failed.golden b/internal/cli/testdata/err_rollout_failed.golden new file mode 100644 index 0000000..8d803cc --- /dev/null +++ b/internal/cli/testdata/err_rollout_failed.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code rollout_failed +--- exit 8 +--- stdout +--- stderr +Error: failed with rollout_failed +Hint: try again diff --git a/internal/cli/testdata/err_timeout.golden b/internal/cli/testdata/err_timeout.golden new file mode 100644 index 0000000..faefc16 --- /dev/null +++ b/internal/cli/testdata/err_timeout.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code timeout +--- exit 6 +--- stdout +--- stderr +Error: failed with timeout +Hint: try again diff --git a/internal/cli/testdata/err_unavailable.golden b/internal/cli/testdata/err_unavailable.golden new file mode 100644 index 0000000..9593ba6 --- /dev/null +++ b/internal/cli/testdata/err_unavailable.golden @@ -0,0 +1,6 @@ +$ devlyft fail --code unavailable +--- exit 1 +--- stdout +--- stderr +Error: failed with unavailable +Hint: try again diff --git a/internal/cli/testdata/http_401.golden b/internal/cli/testdata/http_401.golden new file mode 100644 index 0000000..1ba98d9 --- /dev/null +++ b/internal/cli/testdata/http_401.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 401 +--- exit 3 +--- stdout +--- stderr +Error: server said no diff --git a/internal/cli/testdata/http_403_json.golden b/internal/cli/testdata/http_403_json.golden new file mode 100644 index 0000000..b9791bd --- /dev/null +++ b/internal/cli/testdata/http_403_json.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 403 --request-id req-123 --jq . +--- exit 3 +--- stdout +--- stderr +{"code":"permission_denied","message":"server said no","http_status":403,"request_id":"req-123","details":null,"retryable":false} diff --git a/internal/cli/testdata/http_404.golden b/internal/cli/testdata/http_404.golden new file mode 100644 index 0000000..644d0ea --- /dev/null +++ b/internal/cli/testdata/http_404.golden @@ -0,0 +1,6 @@ +$ devlyft fail --status 404 --request-id req-123 +--- exit 4 +--- stdout +--- stderr +Error: server said no +Request ID: req-123 diff --git a/internal/cli/testdata/http_409.golden b/internal/cli/testdata/http_409.golden new file mode 100644 index 0000000..26022cc --- /dev/null +++ b/internal/cli/testdata/http_409.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 409 +--- exit 5 +--- stdout +--- stderr +Error: server said no diff --git a/internal/cli/testdata/http_412.golden b/internal/cli/testdata/http_412.golden new file mode 100644 index 0000000..17a41d8 --- /dev/null +++ b/internal/cli/testdata/http_412.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 412 +--- exit 5 +--- stdout +--- stderr +Error: server said no diff --git a/internal/cli/testdata/http_422.golden b/internal/cli/testdata/http_422.golden new file mode 100644 index 0000000..2ba96a3 --- /dev/null +++ b/internal/cli/testdata/http_422.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 422 +--- exit 1 +--- stdout +--- stderr +Error: server said no diff --git a/internal/cli/testdata/http_429_json.golden b/internal/cli/testdata/http_429_json.golden new file mode 100644 index 0000000..d08fc90 --- /dev/null +++ b/internal/cli/testdata/http_429_json.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 429 --format json +--- exit 1 +--- stdout +--- stderr +{"code":"unavailable","message":"server said no","http_status":429,"request_id":null,"details":null,"retryable":true} diff --git a/internal/cli/testdata/http_503.golden b/internal/cli/testdata/http_503.golden new file mode 100644 index 0000000..e75bab0 --- /dev/null +++ b/internal/cli/testdata/http_503.golden @@ -0,0 +1,5 @@ +$ devlyft fail --status 503 +--- exit 1 +--- stdout +--- stderr +Error: server said no diff --git a/internal/cli/testdata/interrupted.golden b/internal/cli/testdata/interrupted.golden new file mode 100644 index 0000000..cf05adf --- /dev/null +++ b/internal/cli/testdata/interrupted.golden @@ -0,0 +1,5 @@ +$ devlyft fail --code canceled +--- exit 130 +--- stdout +--- stderr +Interrupted. diff --git a/internal/cli/testdata/interrupted_json.golden b/internal/cli/testdata/interrupted_json.golden new file mode 100644 index 0000000..0bc3c07 --- /dev/null +++ b/internal/cli/testdata/interrupted_json.golden @@ -0,0 +1,5 @@ +$ devlyft fail --code canceled --format json +--- exit 130 +--- stdout +--- stderr +Interrupted. diff --git a/internal/cli/testdata/list_default_pipe.golden b/internal/cli/testdata/list_default_pipe.golden new file mode 100644 index 0000000..5d2d5e4 --- /dev/null +++ b/internal/cli/testdata/list_default_pipe.golden @@ -0,0 +1,6 @@ +$ devlyft widgets +--- exit 0 +--- stdout +0b1c web +7f2e worker with tab +--- stderr diff --git a/internal/cli/testdata/list_default_tty.golden b/internal/cli/testdata/list_default_tty.golden new file mode 100644 index 0000000..95dda94 --- /dev/null +++ b/internal/cli/testdata/list_default_tty.golden @@ -0,0 +1,7 @@ +$ devlyft widgets +--- exit 0 +--- stdout +ID NAME +0b1c web +7f2e worker with tab +--- stderr diff --git a/internal/cli/testdata/list_default_tty_no_color.golden b/internal/cli/testdata/list_default_tty_no_color.golden new file mode 100644 index 0000000..4e5aeed --- /dev/null +++ b/internal/cli/testdata/list_default_tty_no_color.golden @@ -0,0 +1,7 @@ +$ devlyft widgets --no-color +--- exit 0 +--- stdout +ID NAME +0b1c web +7f2e worker with tab +--- stderr diff --git a/internal/cli/testdata/list_jq_objects.golden b/internal/cli/testdata/list_jq_objects.golden new file mode 100644 index 0000000..f2d164a --- /dev/null +++ b/internal/cli/testdata/list_jq_objects.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --format json --jq .[] | {id} +--- exit 0 +--- stdout +{"id":"0b1c"} +{"id":"7f2e"} +--- stderr diff --git a/internal/cli/testdata/list_jq_strings.golden b/internal/cli/testdata/list_jq_strings.golden new file mode 100644 index 0000000..1c8d442 --- /dev/null +++ b/internal/cli/testdata/list_jq_strings.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --jq .[].name +--- exit 0 +--- stdout +web +worker with tab +--- stderr diff --git a/internal/cli/testdata/list_json.golden b/internal/cli/testdata/list_json.golden new file mode 100644 index 0000000..db19158 --- /dev/null +++ b/internal/cli/testdata/list_json.golden @@ -0,0 +1,17 @@ +$ devlyft widgets --format json +--- exit 0 +--- stdout +[ + { + "id": "0b1c", + "name": "web", + "labels": { + "tier": "frontend" + } + }, + { + "id": "7f2e", + "name": "worker\twith tab" + } +] +--- stderr diff --git a/internal/cli/testdata/list_quiet.golden b/internal/cli/testdata/list_quiet.golden new file mode 100644 index 0000000..9760276 --- /dev/null +++ b/internal/cli/testdata/list_quiet.golden @@ -0,0 +1,6 @@ +$ devlyft widgets -q +--- exit 0 +--- stdout +0b1c +7f2e +--- stderr diff --git a/internal/cli/testdata/list_table.golden b/internal/cli/testdata/list_table.golden new file mode 100644 index 0000000..f473e98 --- /dev/null +++ b/internal/cli/testdata/list_table.golden @@ -0,0 +1,7 @@ +$ devlyft widgets --format table +--- exit 0 +--- stdout +ID NAME +0b1c web +7f2e worker with tab +--- stderr diff --git a/internal/cli/testdata/list_tsv.golden b/internal/cli/testdata/list_tsv.golden new file mode 100644 index 0000000..846ad09 --- /dev/null +++ b/internal/cli/testdata/list_tsv.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --format=tsv +--- exit 0 +--- stdout +0b1c web +7f2e worker with tab +--- stderr diff --git a/internal/cli/testdata/list_yaml.golden b/internal/cli/testdata/list_yaml.golden new file mode 100644 index 0000000..0dd466f --- /dev/null +++ b/internal/cli/testdata/list_yaml.golden @@ -0,0 +1,10 @@ +$ devlyft widgets --format yaml +--- exit 0 +--- stdout +- id: 0b1c + labels: + tier: frontend + name: web +- id: 7f2e + name: "worker\twith tab" +--- stderr diff --git a/internal/cli/testdata/usage_bad_format.golden b/internal/cli/testdata/usage_bad_format.golden new file mode 100644 index 0000000..efcc1c7 --- /dev/null +++ b/internal/cli/testdata/usage_bad_format.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --format xml +--- exit 2 +--- stdout +--- stderr +Error: invalid --format "xml": must be one of table, tsv, json, yaml +Hint: Run 'devlyft widgets --help' for usage. diff --git a/internal/cli/testdata/usage_bad_jq.golden b/internal/cli/testdata/usage_bad_jq.golden new file mode 100644 index 0000000..9f41a67 --- /dev/null +++ b/internal/cli/testdata/usage_bad_jq.golden @@ -0,0 +1,5 @@ +$ devlyft widgets --jq .[ +--- exit 2 +--- stdout +--- stderr +{"code":"invalid_argument","message":"invalid --jq expression: unexpected EOF","http_status":null,"request_id":null,"details":null,"retryable":false} diff --git a/internal/cli/testdata/usage_bad_verbosity.golden b/internal/cli/testdata/usage_bad_verbosity.golden new file mode 100644 index 0000000..550c640 --- /dev/null +++ b/internal/cli/testdata/usage_bad_verbosity.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --verbosity loud +--- exit 2 +--- stdout +--- stderr +Error: invalid --verbosity "loud": must be one of debug, info, warning, error, none +Hint: Run 'devlyft widgets --help' for usage. diff --git a/internal/cli/testdata/usage_extra_args.golden b/internal/cli/testdata/usage_extra_args.golden new file mode 100644 index 0000000..b3e191c --- /dev/null +++ b/internal/cli/testdata/usage_extra_args.golden @@ -0,0 +1,6 @@ +$ devlyft fail extra +--- exit 2 +--- stdout +--- stderr +Error: unknown command "extra" for "devlyft fail" +Hint: Run 'devlyft fail --help' for usage. diff --git a/internal/cli/testdata/usage_jq_with_yaml.golden b/internal/cli/testdata/usage_jq_with_yaml.golden new file mode 100644 index 0000000..412548f --- /dev/null +++ b/internal/cli/testdata/usage_jq_with_yaml.golden @@ -0,0 +1,5 @@ +$ devlyft widgets --format yaml --jq . +--- exit 2 +--- stdout +--- stderr +{"code":"invalid_argument","message":"--jq cannot be combined with --format yaml","http_status":null,"request_id":null,"details":null,"retryable":false} diff --git a/internal/cli/testdata/usage_quiet_with_json.golden b/internal/cli/testdata/usage_quiet_with_json.golden new file mode 100644 index 0000000..325e27d --- /dev/null +++ b/internal/cli/testdata/usage_quiet_with_json.golden @@ -0,0 +1,5 @@ +$ devlyft widgets -q --format json +--- exit 2 +--- stdout +--- stderr +{"code":"invalid_argument","message":"--quiet cannot be combined with --format or --jq","http_status":null,"request_id":null,"details":null,"retryable":false} diff --git a/internal/cli/testdata/usage_unknown_command.golden b/internal/cli/testdata/usage_unknown_command.golden new file mode 100644 index 0000000..af73def --- /dev/null +++ b/internal/cli/testdata/usage_unknown_command.golden @@ -0,0 +1,6 @@ +$ devlyft nope +--- exit 2 +--- stdout +--- stderr +Error: unknown command "nope" for "devlyft" +Hint: Run 'devlyft --help' for usage. diff --git a/internal/cli/testdata/usage_unknown_flag.golden b/internal/cli/testdata/usage_unknown_flag.golden new file mode 100644 index 0000000..7afeb36 --- /dev/null +++ b/internal/cli/testdata/usage_unknown_flag.golden @@ -0,0 +1,6 @@ +$ devlyft widgets --bogus +--- exit 2 +--- stdout +--- stderr +Error: unknown flag: --bogus +Hint: Run 'devlyft widgets --help' for usage. diff --git a/internal/cli/testdata/usage_unknown_flag_json.golden b/internal/cli/testdata/usage_unknown_flag_json.golden new file mode 100644 index 0000000..57f68f8 --- /dev/null +++ b/internal/cli/testdata/usage_unknown_flag_json.golden @@ -0,0 +1,5 @@ +$ devlyft widgets --bogus --format json +--- exit 2 +--- stdout +--- stderr +{"code":"invalid_argument","message":"unknown flag: --bogus","http_status":null,"request_id":null,"details":null,"retryable":false} diff --git a/internal/clierr/clierr.go b/internal/clierr/clierr.go new file mode 100644 index 0000000..27a50a9 --- /dev/null +++ b/internal/clierr/clierr.go @@ -0,0 +1,153 @@ +// Package clierr defines the error type every command returns and the single +// mapping from errors to stable error codes and exit codes. +package clierr + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/devlyft/cli/internal/exitcode" +) + +// Code is a stable, machine-readable error code. Values are part of the +// public contract: they may be added but never changed or removed within a +// major version. +type Code string + +// Error codes. +const ( + CodeAuthRequired Code = "auth_required" + CodePermissionDenied Code = "permission_denied" + CodeNotFound Code = "not_found" + CodeConflict Code = "conflict" + CodeInvalidArgument Code = "invalid_argument" + CodeTimeout Code = "timeout" + CodePlacementPending Code = "placement_pending" + CodeRolloutFailed Code = "rollout_failed" + CodeUnavailable Code = "unavailable" + CodeInternal Code = "internal" +) + +// Error is a failure the CLI reports to the user. +type Error struct { + Code Code + Message string + // Hint is an optional next step, shown to humans only. + Hint string + // HTTPStatus is the response status for API errors, or 0. + HTTPStatus int + // RequestID is the server's request ID, when it sent one. + RequestID string + // Details carries structured, code-specific information. + Details map[string]any + // Retryable reports whether running the command again may succeed. + Retryable bool + // Usage marks a mistake caught locally, which exits with exitcode.Usage. + Usage bool + // Err is the underlying cause, if any. + Err error +} + +func (e *Error) Error() string { + if e.Err != nil && e.Message == "" { + return e.Err.Error() + } + return e.Message +} + +func (e *Error) Unwrap() error { return e.Err } + +// ExitCode returns the process exit code for the error. +func (e *Error) ExitCode() int { + if e.Usage { + return exitcode.Usage + } + switch e.Code { + case CodeAuthRequired, CodePermissionDenied: + return exitcode.Auth + case CodeNotFound: + return exitcode.NotFound + case CodeConflict: + return exitcode.Conflict + case CodeTimeout: + return exitcode.Timeout + case CodePlacementPending: + return exitcode.PlacementPending + case CodeRolloutFailed: + return exitcode.RolloutFailed + case CodeInvalidArgument, CodeUnavailable, CodeInternal: + return exitcode.Error + } + return exitcode.Error +} + +// New returns an error with the given code and message. +func New(code Code, format string, args ...any) *Error { + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Usagef returns a usage error: a mistake the CLI caught locally. +func Usagef(format string, args ...any) *Error { + return &Error{Code: CodeInvalidArgument, Message: fmt.Sprintf(format, args...), Usage: true} +} + +// FromHTTP builds an error from an API response status. message should be +// the server's error message when it sent one. +func FromHTTP(status int, message, requestID string) *Error { + e := &Error{Code: codeForStatus(status), Message: message, HTTPStatus: status, RequestID: requestID} + e.Retryable = status == http.StatusTooManyRequests || status >= http.StatusInternalServerError + if e.Message == "" { + e.Message = http.StatusText(status) + } + return e +} + +func codeForStatus(status int) Code { + switch { + case status == http.StatusUnauthorized: + return CodeAuthRequired + case status == http.StatusForbidden: + return CodePermissionDenied + case status == http.StatusNotFound: + return CodeNotFound + case status == http.StatusConflict, status == http.StatusPreconditionFailed: + return CodeConflict + case status == http.StatusRequestTimeout, status == http.StatusGatewayTimeout: + return CodeUnavailable + case status == http.StatusTooManyRequests, status >= http.StatusInternalServerError: + return CodeUnavailable + case status >= http.StatusBadRequest: + return CodeInvalidArgument + } + return CodeInternal +} + +// IsInterrupted reports whether err was caused by the user pressing Ctrl-C. +func IsInterrupted(err error) bool { + return errors.Is(err, context.Canceled) +} + +// As converts any error into an *Error. Errors that are not already an +// *Error become CodeInternal, except a deadline, which becomes CodeTimeout. +func As(err error) *Error { + if e, ok := errors.AsType[*Error](err); ok { + return e + } + if errors.Is(err, context.DeadlineExceeded) { + return &Error{Code: CodeTimeout, Message: err.Error(), Err: err, Retryable: true} + } + return &Error{Code: CodeInternal, Message: err.Error(), Err: err} +} + +// ExitCode returns the process exit code for any error; nil is exitcode.OK. +func ExitCode(err error) int { + switch { + case err == nil: + return exitcode.OK + case IsInterrupted(err): + return exitcode.Interrupted + } + return As(err).ExitCode() +} diff --git a/internal/clierr/clierr_test.go b/internal/clierr/clierr_test.go new file mode 100644 index 0000000..8e1833d --- /dev/null +++ b/internal/clierr/clierr_test.go @@ -0,0 +1,88 @@ +package clierr + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "github.com/devlyft/cli/internal/exitcode" +) + +// The exit code for each error code is part of the public contract. +func TestExitCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + want int + }{ + {nil, exitcode.OK}, + {New(CodeInternal, "x"), exitcode.Error}, + {New(CodeUnavailable, "x"), exitcode.Error}, + {New(CodeInvalidArgument, "server-side validation"), exitcode.Error}, + {Usagef("bad flag"), exitcode.Usage}, + {New(CodeAuthRequired, "x"), exitcode.Auth}, + {New(CodePermissionDenied, "x"), exitcode.Auth}, + {New(CodeNotFound, "x"), exitcode.NotFound}, + {New(CodeConflict, "x"), exitcode.Conflict}, + {New(CodeTimeout, "x"), exitcode.Timeout}, + {New(CodePlacementPending, "x"), exitcode.PlacementPending}, + {New(CodeRolloutFailed, "x"), exitcode.RolloutFailed}, + {fmt.Errorf("wrapped: %w", New(CodeNotFound, "x")), exitcode.NotFound}, + {context.DeadlineExceeded, exitcode.Timeout}, + {fmt.Errorf("stopped: %w", context.Canceled), exitcode.Interrupted}, + {errors.New("plain"), exitcode.Error}, + } + for _, tt := range tests { + if got := ExitCode(tt.err); got != tt.want { + t.Errorf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want) + } + } +} + +func TestFromHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + status int + code Code + exit int + retryable bool + }{ + {400, CodeInvalidArgument, exitcode.Error, false}, + {401, CodeAuthRequired, exitcode.Auth, false}, + {403, CodePermissionDenied, exitcode.Auth, false}, + {404, CodeNotFound, exitcode.NotFound, false}, + {409, CodeConflict, exitcode.Conflict, false}, + {412, CodeConflict, exitcode.Conflict, false}, + {422, CodeInvalidArgument, exitcode.Error, false}, + {429, CodeUnavailable, exitcode.Error, true}, + {500, CodeUnavailable, exitcode.Error, true}, + {503, CodeUnavailable, exitcode.Error, true}, + } + for _, tt := range tests { + e := FromHTTP(tt.status, "", "req-1") + if e.Code != tt.code || e.ExitCode() != tt.exit || e.Retryable != tt.retryable { + t.Errorf("FromHTTP(%d) = code %s exit %d retryable %v, want %s %d %v", + tt.status, e.Code, e.ExitCode(), e.Retryable, tt.code, tt.exit, tt.retryable) + } + if e.Message == "" || e.RequestID != "req-1" { + t.Errorf("FromHTTP(%d) lost message or request ID: %+v", tt.status, e) + } + } +} + +func TestWriteJSONShape(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := WriteJSON(&buf, New(CodeNotFound, "deploy %q not found", "web")); err != nil { + t.Fatal(err) + } + want := `{"code":"not_found","message":"deploy \"web\" not found","http_status":null,"request_id":null,"details":null,"retryable":false}` + "\n" + if buf.String() != want { + t.Errorf("got %s\nwant %s", buf.String(), want) + } +} diff --git a/internal/clierr/print.go b/internal/clierr/print.go new file mode 100644 index 0000000..5a68bd9 --- /dev/null +++ b/internal/clierr/print.go @@ -0,0 +1,58 @@ +package clierr + +import ( + "encoding/json" + "fmt" + "io" +) + +// jsonError is the shape of an error under --format json. Every field is +// always present so scripts can rely on it. +type jsonError struct { + Code Code `json:"code"` + Message string `json:"message"` + HTTPStatus *int `json:"http_status"` + RequestID *string `json:"request_id"` + Details map[string]any `json:"details"` + Retryable bool `json:"retryable"` +} + +// WriteJSON writes err to w as a single-line JSON object. +func WriteJSON(w io.Writer, err *Error) error { + je := jsonError{ + Code: err.Code, + Message: err.Error(), + Details: err.Details, + Retryable: err.Retryable, + } + if err.HTTPStatus != 0 { + je.HTTPStatus = &err.HTTPStatus + } + if err.RequestID != "" { + je.RequestID = &err.RequestID + } + return json.NewEncoder(w).Encode(je) +} + +// WriteHuman writes err to w as a summary line, then an optional hint and +// request ID. +func WriteHuman(w io.Writer, err *Error, color bool) error { + prefix := "Error:" + if color { + prefix = "\x1b[1;31mError:\x1b[0m" + } + if _, werr := fmt.Fprintf(w, "%s %s\n", prefix, err.Error()); werr != nil { + return werr + } + if err.Hint != "" { + if _, werr := fmt.Fprintf(w, "Hint: %s\n", err.Hint); werr != nil { + return werr + } + } + if err.RequestID != "" { + if _, werr := fmt.Fprintf(w, "Request ID: %s\n", err.RequestID); werr != nil { + return werr + } + } + return nil +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go new file mode 100644 index 0000000..36b97c2 --- /dev/null +++ b/internal/cmdutil/factory.go @@ -0,0 +1,21 @@ +// Package cmdutil holds what every command needs: streams, the result +// printer and the debug logger. Command packages receive a *Factory when +// they are constructed; its fields are ready by the time RunE runs. +package cmdutil + +import ( + "log/slog" + + "github.com/devlyft/cli/internal/iostreams" + "github.com/devlyft/cli/internal/output" +) + +// Factory carries shared dependencies to commands. +type Factory struct { + IO *iostreams.IOStreams + Printer *output.Printer + Log *slog.Logger +} + +// YesFlagUsage is the help text for the --yes flag on destructive commands. +const YesFlagUsage = "Skip the confirmation prompt (required when not running interactively)" diff --git a/internal/cmdutil/log.go b/internal/cmdutil/log.go new file mode 100644 index 0000000..0bf62ed --- /dev/null +++ b/internal/cmdutil/log.go @@ -0,0 +1,75 @@ +package cmdutil + +import ( + "context" + "io" + "log/slog" + "strings" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/redact" +) + +// Verbosities are the levels accepted by --verbosity, as in gcloud. +var Verbosities = []string{"debug", "info", "warning", "error", "none"} + +// ParseVerbosity converts a --verbosity value to a log level. "none" +// returns ok=false: nothing is logged. +func ParseVerbosity(s string) (level slog.Level, ok bool, err error) { + switch s { + case "debug": + return slog.LevelDebug, true, nil + case "info": + return slog.LevelInfo, true, nil + case "warning": + return slog.LevelWarn, true, nil + case "error": + return slog.LevelError, true, nil + case "none": + return 0, false, nil + } + return 0, false, clierr.Usagef("invalid --verbosity %q: must be one of %s", s, strings.Join(Verbosities, ", ")) +} + +// NewLogger returns a logger writing to w at the given level, with every +// message and string attribute passed through redaction. A nil logger is +// never returned: when enabled is false the logger discards everything. +func NewLogger(w io.Writer, level slog.Level, enabled bool) *slog.Logger { + if !enabled { + return slog.New(slog.DiscardHandler) + } + h := slog.NewTextHandler(w, &slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + if a.Value.Kind() == slog.KindString { + a.Value = slog.StringValue(redact.String(a.Value.String())) + } + return a + }, + }) + return slog.New(redactingHandler{h}) +} + +// redactingHandler redacts the log message; ReplaceAttr covers attributes. +type redactingHandler struct{ slog.Handler } + +//nolint:gocritic // signature fixed by slog.Handler +func (h redactingHandler) Handle(ctx context.Context, r slog.Record) error { + clean := slog.NewRecord(r.Time, r.Level, redact.String(r.Message), r.PC) + r.Attrs(func(a slog.Attr) bool { + clean.AddAttrs(a) + return true + }) + return h.Handler.Handle(ctx, clean) +} + +func (h redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return redactingHandler{h.Handler.WithAttrs(attrs)} +} + +func (h redactingHandler) WithGroup(name string) slog.Handler { + return redactingHandler{h.Handler.WithGroup(name)} +} diff --git a/internal/cmdutil/log_test.go b/internal/cmdutil/log_test.go new file mode 100644 index 0000000..5e2e02f --- /dev/null +++ b/internal/cmdutil/log_test.go @@ -0,0 +1,61 @@ +package cmdutil + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestLoggerRedacts(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := NewLogger(&buf, slog.LevelDebug, true) + log.Debug("request Authorization: Bearer s3cret", + "body", `{"refresh_token":"s3cret"}`, + slog.Group("req", "url", "/token?code=s3cret")) + log.With("header", "Bearer s3cret").Debug("with attrs") + + out := buf.String() + if strings.Contains(out, "s3cret") { + t.Errorf("log leaked secret:\n%s", out) + } + if strings.Count(out, "[REDACTED]") < 4 { + t.Errorf("expected four redactions:\n%s", out) + } +} + +func TestLoggerLevels(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := NewLogger(&buf, slog.LevelWarn, true) + log.Debug("hidden") + log.Warn("shown") + if strings.Contains(buf.String(), "hidden") || !strings.Contains(buf.String(), "shown") { + t.Errorf("unexpected output: %q", buf.String()) + } + + buf.Reset() + NewLogger(&buf, slog.LevelDebug, false).Error("nothing") + if buf.Len() != 0 { + t.Errorf("disabled logger wrote %q", buf.String()) + } +} + +func TestParseVerbosity(t *testing.T) { + t.Parallel() + + for _, v := range Verbosities { + if _, _, err := ParseVerbosity(v); err != nil { + t.Errorf("ParseVerbosity(%q): %v", v, err) + } + } + if _, enabled, _ := ParseVerbosity("none"); enabled { + t.Error("none should disable logging") + } + if _, _, err := ParseVerbosity("loud"); err == nil { + t.Error("ParseVerbosity(loud) should fail") + } +} diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go index 79d0fa0..1ae9927 100644 --- a/internal/exitcode/exitcode.go +++ b/internal/exitcode/exitcode.go @@ -5,6 +5,24 @@ package exitcode const ( // OK means the command succeeded. OK = 0 - // Error is any failure without a more specific code. + // Error is any failure without a more specific code: 5xx after retries, + // network failures and server-side validation. Error = 1 + // Usage is a mistake caught locally: a bad flag, a missing value, or a + // prompt that is needed without a TTY. + Usage = 2 + // Auth is a missing credential or a 401 or 403 response. + Auth = 3 + // NotFound is a 404 or a name that resolves to nothing. + NotFound = 4 + // Conflict is a 409 or 412 response. + Conflict = 5 + // Timeout means --wait or --timeout expired. + Timeout = 6 + // PlacementPending means no cluster can take the deploy yet. + PlacementPending = 7 + // RolloutFailed means pods failed or are crash-looping. + RolloutFailed = 8 + // Interrupted means the user pressed Ctrl-C (128 + SIGINT). + Interrupted = 130 ) diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go new file mode 100644 index 0000000..8ff53d6 --- /dev/null +++ b/internal/iostreams/iostreams.go @@ -0,0 +1,78 @@ +// Package iostreams holds the process's standard streams and what is known +// about them: whether each is a terminal and whether colour is wanted. +package iostreams + +import ( + "bytes" + "io" + "os" + + "golang.org/x/term" +) + +// IOStreams are the streams a command reads from and writes to. Results go +// to Out, diagnostics and progress to ErrOut. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + + stdinTTY bool + stdoutTTY bool + stderrTTY bool + colorOff bool + dumbTerm bool +} + +// System returns streams bound to the process's stdin, stdout and stderr. +// Colour is disabled when NO_COLOR is set to any non-empty value or TERM is +// "dumb". +func System() *IOStreams { + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + stdinTTY: isTerminal(os.Stdin), + stdoutTTY: isTerminal(os.Stdout), + stderrTTY: isTerminal(os.Stderr), + colorOff: os.Getenv("NO_COLOR") != "", + dumbTerm: os.Getenv("TERM") == "dumb", + } +} + +// Test returns streams backed by buffers, none of them a terminal. +func Test() (ios *IOStreams, in, out, errOut *bytes.Buffer) { + in, out, errOut = &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} + return &IOStreams{In: in, Out: out, ErrOut: errOut}, in, out, errOut +} + +func isTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) //nolint:gosec // file descriptors fit in an int +} + +// SetTTY marks all three streams as terminals or not. Used by tests. +func (s *IOStreams) SetTTY(tty bool) { + s.stdinTTY, s.stdoutTTY, s.stderrTTY = tty, tty, tty +} + +// DisableColor turns colour off, as --no-color does. +func (s *IOStreams) DisableColor() { s.colorOff = true } + +// IsStdoutTTY reports whether stdout is a terminal. +func (s *IOStreams) IsStdoutTTY() bool { return s.stdoutTTY } + +// IsStderrTTY reports whether stderr is a terminal. +func (s *IOStreams) IsStderrTTY() bool { return s.stderrTTY } + +// CanPrompt reports whether the CLI may ask the user a question. It never +// prompts unless both stdin and stderr are terminals. +func (s *IOStreams) CanPrompt() bool { return s.stdinTTY && s.stderrTTY } + +// ColorEnabled reports whether stdout output may use colour. +func (s *IOStreams) ColorEnabled() bool { return s.stdoutTTY && !s.colorOff && !s.dumbTerm } + +// ErrColorEnabled reports whether stderr output may use colour. +func (s *IOStreams) ErrColorEnabled() bool { return s.stderrTTY && !s.colorOff && !s.dumbTerm } + +// SpinnersEnabled reports whether progress spinners may be shown on stderr. +func (s *IOStreams) SpinnersEnabled() bool { return s.stderrTTY && !s.dumbTerm } diff --git a/internal/output/doc.go b/internal/output/doc.go deleted file mode 100644 index d0441bc..0000000 --- a/internal/output/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package output prints results as tables, JSON or YAML and detects -// terminals and colour support. -package output diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..43f793d --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,266 @@ +// Package output prints command results as a table, TSV, JSON or YAML. +// +// JSON and YAML print the API resource shape and are semver-stable: fields +// may be added but never removed or retyped within a major version. Tables +// and TSV are for people and are not stable. +package output + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/itchyny/gojq" + "sigs.k8s.io/yaml" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/iostreams" +) + +// Format is an output format. +type Format string + +// Output formats. FormatDefault is a table at a terminal and TSV otherwise. +const ( + FormatDefault Format = "" + FormatTable Format = "table" + FormatTSV Format = "tsv" + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +// Formats lists the values accepted by --format. +var Formats = []Format{FormatTable, FormatTSV, FormatJSON, FormatYAML} + +// ParseFormat validates a --format value. +func ParseFormat(s string) (Format, error) { + if s == "" { + return FormatDefault, nil + } + for _, f := range Formats { + if string(f) == s { + return f, nil + } + } + names := make([]string, len(Formats)) + for i, f := range Formats { + names[i] = string(f) + } + return "", clierr.Usagef("invalid --format %q: must be one of %s", s, strings.Join(names, ", ")) +} + +// Options are the global output flags. +type Options struct { + Format Format + // JQ is a jq expression applied to the JSON output. + JQ string + // Quiet prints only resource IDs. + Quiet bool +} + +// Validate checks that the options can be combined. +func (o Options) Validate() error { + if o.JQ != "" && o.Format != FormatDefault && o.Format != FormatJSON { + return clierr.Usagef("--jq cannot be combined with --format %s", o.Format) + } + if o.Quiet && (o.JQ != "" || o.Format != FormatDefault) { + return clierr.Usagef("--quiet cannot be combined with --format or --jq") + } + if o.JQ != "" { + if _, err := gojq.Parse(o.JQ); err != nil { + return clierr.Usagef("invalid --jq expression: %v", err) + } + } + return nil +} + +// IsJSON reports whether results, and errors, are printed as JSON. +func (o Options) IsJSON() bool { return o.Format == FormatJSON || o.JQ != "" } + +// Table is the human-readable form of a result. +type Table struct { + Headers []string + Rows [][]string +} + +// Result is what a command prints. +type Result struct { + // Data is the API resource or list of resources, printed by JSON, YAML + // and --jq. + Data any + // Table is printed for the table and TSV formats. A nil Table falls + // back to YAML, which suits single resources with nested fields. + Table *Table + // IDs are printed one per line by --quiet. + IDs []string +} + +// Printer writes results according to the options. +type Printer struct { + ios *iostreams.IOStreams + opts Options +} + +// NewPrinter returns a printer writing to ios.Out. +func NewPrinter(ios *iostreams.IOStreams, opts Options) *Printer { + return &Printer{ios: ios, opts: opts} +} + +// Options returns the printer's options. +func (p *Printer) Options() Options { return p.opts } + +// Print writes r to stdout. +func (p *Printer) Print(r Result) error { + w := p.ios.Out + switch { + case p.opts.Quiet: + return writeLines(w, r.IDs) + case p.opts.JQ != "": + return writeJQ(w, p.opts.JQ, r.Data) + } + switch p.opts.Format { + case FormatJSON: + return writeJSON(w, r.Data) + case FormatYAML: + return writeYAML(w, r.Data) + case FormatTSV: + return p.writeTable(w, r, false) + case FormatTable: + return p.writeTable(w, r, true) + case FormatDefault: + return p.writeTable(w, r, p.ios.IsStdoutTTY()) + } + return fmt.Errorf("unhandled format %q", p.opts.Format) +} + +func (p *Printer) writeTable(w io.Writer, r Result, aligned bool) error { + if r.Table == nil { + return writeYAML(w, r.Data) + } + if !aligned { + // TSV: no header, so `cut` and `while read` work directly. + for _, row := range r.Table.Rows { + if _, err := fmt.Fprintln(w, strings.Join(sanitizeRow(row), "\t")); err != nil { + return err + } + } + return nil + } + var buf bytes.Buffer + tw := tabwriter.NewWriter(&buf, 0, 0, 3, ' ', 0) + if _, err := fmt.Fprintln(tw, strings.ToUpper(strings.Join(r.Table.Headers, "\t"))); err != nil { + return err + } + for _, row := range r.Table.Rows { + if _, err := fmt.Fprintln(tw, strings.Join(sanitizeRow(row), "\t")); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + out := buf.String() + if p.ios.ColorEnabled() { + // Colour after alignment: escape codes would count as width. + header, rest, _ := strings.Cut(out, "\n") + out = "\x1b[1m" + strings.TrimRight(header, " ") + "\x1b[0m\n" + rest + } + _, err := io.WriteString(w, out) + return err +} + +// sanitizeRow keeps cell values from breaking the row structure. +func sanitizeRow(row []string) []string { + out := make([]string, len(row)) + r := strings.NewReplacer("\t", " ", "\n", " ", "\r", " ") + for i, c := range row { + out[i] = r.Replace(c) + } + return out +} + +func writeLines(w io.Writer, lines []string) error { + for _, l := range lines { + if _, err := fmt.Fprintln(w, l); err != nil { + return err + } + } + return nil +} + +func writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +func writeYAML(w io.Writer, v any) error { + b, err := yaml.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(b) + return err +} + +// writeJQ runs expr over v's JSON form. Strings print raw, like `jq -r`; +// other values print as compact JSON, one per line. +func writeJQ(w io.Writer, expr string, v any) error { + q, err := gojq.Parse(expr) + if err != nil { + return clierr.Usagef("invalid --jq expression: %v", err) + } + input, err := normalize(v) + if err != nil { + return err + } + iter := q.Run(input) + for { + res, ok := iter.Next() + if !ok { + return nil + } + if err, ok := res.(error); ok { + if halt, ok := errors.AsType[*gojq.HaltError](err); ok && halt.Value() == nil { + return nil + } + return clierr.Usagef("--jq: %v", err) + } + if s, ok := res.(string); ok { + if _, err := fmt.Fprintln(w, s); err != nil { + return err + } + continue + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(res); err != nil { + return err + } + if _, err := w.Write(buf.Bytes()); err != nil { + return err + } + } +} + +// normalize converts v into the plain maps, slices and scalars gojq accepts, +// using its JSON encoding so field names match --format json. +func normalize(v any) (any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..2d5037b --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,46 @@ +package output + +import ( + "testing" +) + +func TestParseFormat(t *testing.T) { + t.Parallel() + + for _, f := range append(Formats, FormatDefault) { + if got, err := ParseFormat(string(f)); err != nil || got != f { + t.Errorf("ParseFormat(%q) = %q, %v", f, got, err) + } + } + if _, err := ParseFormat("xml"); err == nil { + t.Error("ParseFormat(xml) should fail") + } +} + +func TestOptionsValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts Options + wantErr bool + }{ + {"defaults", Options{}, false}, + {"jq implies json", Options{JQ: ".[]"}, false}, + {"jq with json", Options{Format: FormatJSON, JQ: ".[]"}, false}, + {"jq with yaml", Options{Format: FormatYAML, JQ: "."}, true}, + {"jq with table", Options{Format: FormatTable, JQ: "."}, true}, + {"invalid jq", Options{JQ: ".["}, true}, + {"quiet", Options{Quiet: true}, false}, + {"quiet with format", Options{Quiet: true, Format: FormatJSON}, true}, + {"quiet with jq", Options{Quiet: true, JQ: "."}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.opts.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Validate() = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..a936117 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,37 @@ +// Package prompt asks the user questions at a terminal. It never prompts +// without one: a command that needs an answer fails with a usage error +// naming the flag to pass instead. +package prompt + +import ( + "bufio" + "fmt" + "strings" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/iostreams" +) + +// Confirm asks a yes/no question for a destructive action. It returns true +// without asking when yes is set (the --yes flag), and a usage error when +// it would need to ask but cannot. +func Confirm(ios *iostreams.IOStreams, question string, yes bool) (bool, error) { + if yes { + return true, nil + } + if !ios.CanPrompt() { + return false, clierr.Usagef("%s: pass --yes to confirm when not running interactively", strings.TrimSuffix(question, "?")) + } + if _, err := fmt.Fprintf(ios.ErrOut, "%s [y/N] ", question); err != nil { + return false, err + } + line, err := bufio.NewReader(ios.In).ReadString('\n') + if err != nil && line == "" { + return false, nil //nolint:nilerr // EOF at the prompt means no + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + } + return false, nil +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 0000000..e1698c0 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,75 @@ +// Package redact removes tokens and secret values from text before it is +// logged. Debug output must pass through it. +package redact + +import ( + "net/http" + "regexp" + "strings" +) + +// Placeholder replaces every redacted value. +const Placeholder = "[REDACTED]" + +// sensitiveHeaders are HTTP headers whose values are always redacted. +var sensitiveHeaders = map[string]bool{ + "Authorization": true, + "Proxy-Authorization": true, + "Cookie": true, + "Set-Cookie": true, + "Sec-Websocket-Protocol": true, + "X-Api-Key": true, +} + +var ( + // "Bearer " anywhere, including the WebSocket "bearer, " + // subprotocol form. + bearerRE = regexp.MustCompile(`(?i)(bearer,?\s+)[A-Za-z0-9\-._~+/]+=*`) + // JSON string fields with a sensitive name. + jsonFieldRE = regexp.MustCompile(`(?i)("(?:[a-z_]*token|secret|password|value|code_verifier|code|client_secret|private_key)"\s*:\s*)"(?:[^"\\]|\\.)*"`) + // Query and form parameters with a sensitive name. + paramRE = regexp.MustCompile(`(?i)\b((?:[a-z_]*token|secret|password|code_verifier|code|client_secret)=)[^&\s"]+`) + // JWTs on their own. + jwtRE = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) +) + +// String redacts bearer tokens, JWTs, sensitive JSON fields and sensitive +// query or form parameters in s. +func String(s string) string { + s = bearerRE.ReplaceAllString(s, "${1}"+Placeholder) + s = jsonFieldRE.ReplaceAllString(s, `${1}"`+Placeholder+`"`) + s = paramRE.ReplaceAllString(s, "${1}"+Placeholder) + s = jwtRE.ReplaceAllString(s, Placeholder) + return s +} + +// Header returns a copy of h with sensitive header values redacted. +func Header(h http.Header) http.Header { + out := make(http.Header, len(h)) + for k, vs := range h { + if sensitiveHeaders[http.CanonicalHeaderKey(k)] { + out[k] = []string{Placeholder} + continue + } + cp := make([]string, len(vs)) + for i, v := range vs { + cp[i] = String(v) + } + out[k] = cp + } + return out +} + +// Secrets returns a function that replaces each of the given literal values +// with the placeholder. Use it for values the CLI knows are secret, such as +// a secret being set, that the patterns above cannot recognise. +func Secrets(values ...string) func(string) string { + var pairs []string + for _, v := range values { + if v != "" { + pairs = append(pairs, v, Placeholder) + } + } + r := strings.NewReplacer(pairs...) + return r.Replace +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 0000000..ddc1f81 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,70 @@ +package redact + +import ( + "net/http" + "strings" + "testing" +) + +const jwt = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1In0.c2lnbmF0dXJl" + +func TestString(t *testing.T) { + t.Parallel() + + tests := []struct { + name, in, want string + }{ + {"bearer header value", "Authorization: Bearer abc.def-123", "Authorization: Bearer [REDACTED]"}, + {"websocket subprotocol", "Sec-WebSocket-Protocol: bearer, abc123", "Sec-WebSocket-Protocol: bearer, [REDACTED]"}, + {"bare jwt", "token is " + jwt + " ok", "token is [REDACTED] ok"}, + {"json access token", `{"access_token":"abc","expires_in":900}`, `{"access_token":"[REDACTED]","expires_in":900}`}, + {"json refresh token with spaces", `{"refresh_token" : "a\"b"}`, `{"refresh_token" : "[REDACTED]"}`}, + {"json secret value", `{"key":"DB_URL","value":"postgres://u:p@h/db"}`, `{"key":"DB_URL","value":"[REDACTED]"}`}, + {"form body", "grant_type=refresh_token&refresh_token=xyz&client_id=devlyft-cli", "grant_type=refresh_token&refresh_token=[REDACTED]&client_id=devlyft-cli"}, + {"query code and verifier", "/token?code=abc&code_verifier=def&state=s", "/token?code=[REDACTED]&code_verifier=[REDACTED]&state=s"}, + {"nothing sensitive", `GET /v1/deploys?name=web 200`, `GET /v1/deploys?name=web 200`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := String(tt.in); got != tt.want { + t.Errorf("String(%q)\n got %q\n want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestHeader(t *testing.T) { + t.Parallel() + + h := http.Header{} + h.Set("Authorization", "Bearer secret-token") + h.Set("Sec-WebSocket-Protocol", "bearer, secret-token") + h.Set("Cookie", "session=secret-token") + h.Set("X-Request-Id", "req-1") + h.Set("Location", "https://example.com/cb?code=secret-token") + + got := Header(h) + for k, vs := range got { + for _, v := range vs { + if strings.Contains(v, "secret-token") { + t.Errorf("header %s leaked secret: %q", k, v) + } + } + } + if got.Get("X-Request-Id") != "req-1" { + t.Errorf("X-Request-Id = %q, want unchanged", got.Get("X-Request-Id")) + } + if h.Get("Authorization") != "Bearer secret-token" { + t.Error("Header modified its input") + } +} + +func TestSecrets(t *testing.T) { + t.Parallel() + + r := Secrets("hunter2", "") + if got := r("password is hunter2"); got != "password is [REDACTED]" { + t.Errorf("got %q", got) + } +}