diff --git a/README.md b/README.md index d2eb9d1aa..d1df68511 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Environment variables include: - `UPSUN_CLI_TOKEN`: an API token, for non-interactive use such as CI. An API token can act as the account that created it, so use a separate machine account to limit its access. Interactively, prefer `upsun auth:api-token-login`. -- `UPSUN_CLI_DEBUG=1`: enable debug output. This can print HTTP request details, +- `UPSUN_CLI_DEBUG=1`: enable debug output, like the `--debug` flag. This can print HTTP request details, including access tokens. - `UPSUN_CLI_DEFAULT_TIMEOUT`: the timeout in seconds for most API requests (default 30). diff --git a/commands/help.go b/commands/help.go index c7c433cdc..e6df1d0ac 100644 --- a/commands/help.go +++ b/commands/help.go @@ -2,6 +2,7 @@ package commands import ( "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/legacy" @@ -13,6 +14,10 @@ func newHelpCommand(cnf *config.Config) *cobra.Command { // Disable flag parsing so flags like --format are preserved for the legacy CLI. DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { + args, debug := stripDebugFlag(args) + if debug != nil { + viper.Set("debug", *debug) + } loadLegacyCmds := enabledLegacyCommands(cnf, legacy.Commands) if expanded, ok, err := expandAbbreviation(cmd.Root(), loadLegacyCmds, args); err != nil { debugLogf("Failed to load the legacy command index: %s", err) diff --git a/commands/root.go b/commands/root.go index 0db2a8240..f2b56c11e 100644 --- a/commands/root.go +++ b/commands/root.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "github.com/fatih/color" @@ -107,7 +108,8 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob }, Run: func(cmd *cobra.Command, _ []string) { c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin()) - if err := c.Exec(cmd.Context(), os.Args[1:]...); err != nil { + args, _ := stripDebugFlag(os.Args[1:]) + if err := c.Exec(cmd.Context(), args...); err != nil { exitWithError(err) } }, @@ -127,6 +129,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob } // Others will be passed to the legacy CLI's help command. + args, _ = stripDebugFlag(args) if !slices.Contains(args, "--help") && !slices.Contains(args, "-h") { args = append([]string{"help"}, args...) } @@ -294,6 +297,29 @@ func debugLogf(format string, v ...any) { fmt.Fprintf(color.Error, prefix+" "+strings.TrimSpace(format)+"\n", v...) } +// stripDebugFlag removes the --debug flag, which the legacy CLI does not accept, from args before any "--" +// separator. It returns the flag's value, or nil if it was not given. +func stripDebugFlag(args []string) (stripped []string, debug *bool) { + stripped = make([]string, 0, len(args)) + for i, arg := range args { + if arg == "--" { + return append(stripped, args[i:]...), debug + } + if arg == "--debug" { + debug = new(true) + continue + } + if v, ok := strings.CutPrefix(arg, "--debug="); ok { + if b, err := strconv.ParseBool(v); err == nil { + debug = new(b) + continue + } + } + stripped = append(stripped, arg) + } + return stripped, debug +} + func exitWithError(err error) { var execErr *exec.ExitError if errors.As(err, &execErr) { @@ -311,6 +337,7 @@ func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io return &legacy.CLIWrapper{ Config: cnf, Version: config.Version, + Debug: viper.GetBool("debug"), DebugLogFunc: debugLogf, DisableInteraction: viper.GetBool("no-interaction"), Stdout: stdout, diff --git a/commands/root_test.go b/commands/root_test.go index fddd8066a..0a577f1e2 100644 --- a/commands/root_test.go +++ b/commands/root_test.go @@ -1,6 +1,7 @@ package commands import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -52,3 +53,27 @@ func TestShellQuote(t *testing.T) { assert.Equal(t, c.want, shellQuote(c.in)) } } + +func TestStripDebugFlag(t *testing.T) { + cases := []struct { + args []string + want []string + wantDebug *bool + }{ + {[]string{"cc"}, []string{"cc"}, nil}, + {[]string{"cc", "--debug"}, []string{"cc"}, new(true)}, + {[]string{"--debug", "cc", "-v"}, []string{"cc", "-v"}, new(true)}, + {[]string{"cc", "--debug=1"}, []string{"cc"}, new(true)}, + {[]string{"cc", "--debug=false"}, []string{"cc"}, new(false)}, + {[]string{"cc", "--debug=foo"}, []string{"cc", "--debug=foo"}, nil}, + {[]string{"ssh", "--", "cmd", "--debug"}, []string{"ssh", "--", "cmd", "--debug"}, nil}, + {[]string{"ssh", "--debug", "--", "--debug"}, []string{"ssh", "--", "--debug"}, new(true)}, + } + for _, c := range cases { + t.Run(strings.Join(c.args, " "), func(t *testing.T) { + got, debug := stripDebugFlag(c.args) + assert.Equal(t, c.want, got) + assert.Equal(t, c.wantDebug, debug) + }) + } +} diff --git a/integration-tests/debug_test.go b/integration-tests/debug_test.go new file mode 100644 index 000000000..6a03373c8 --- /dev/null +++ b/integration-tests/debug_test.go @@ -0,0 +1,39 @@ +package tests + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDebugFlag checks that --debug enables debug logging and is not forwarded to the legacy CLI. +func TestDebugFlag(t *testing.T) { + f := newCommandFactory(t, "", "") + + cases := []struct { + args []string + expected string + }{ + {[]string{"cc", "--debug"}, ""}, + {[]string{"--debug", "cc"}, ""}, + {[]string{"cc", "--debug", "--help"}, "Command: clear-cache"}, + {[]string{"--help", "--debug", "cc"}, "Command: clear-cache"}, + {[]string{"help", "cc", "--debug"}, "Command: clear-cache"}, + {[]string{"help", "--debug", "cc"}, "Command: clear-cache"}, + {[]string{"--debug", "p:init", "--help"}, "Command: project:init"}, + } + for _, c := range cases { + t.Run(strings.Join(c.args, " "), func(t *testing.T) { + stdOut, stdErr, err := f.RunCombinedOutput(c.args...) + require.NoError(t, err, stdErr) + assert.NotContains(t, stdErr, `"--debug" option does not exist`) + assert.Contains(t, stdOut, c.expected) + if c.expected != "Command: project:init" { + // Logged by the Go wrapper when it starts the legacy CLI. + assert.Contains(t, stdErr, "Initialized PHP CLI") + } + }) + } +} diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 1e01fb91c..609083be9 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -149,6 +149,12 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { if c.DisableInteraction { cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") } + // Override any inherited value, so that --debug=false also applies to the legacy CLI. + if c.Debug { + cmd.Env = append(cmd.Env, envPrefix+"DEBUG=1") + } else { + cmd.Env = append(cmd.Env, envPrefix+"DEBUG=0") + } if c.ForceColor { cmd.Env = append(cmd.Env, "CLICOLOR_FORCE=1") }