From 078354ea077778d45fa79de24cb7ebc95276ff7c Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 11:03:57 +0200 Subject: [PATCH 1/8] fix: hint CLI update command on cli_upgrade_required errors The auth server rejects too-old builds with the cli_upgrade_required OAuth code, but the CLI printed only the raw code with no way forward. Append the install-manager-aware update command (same one the periodic version check advertises) in main.go's central error-print arm, so login, token refresh, and RFC 8693 exchange failures all get the hint without per-command wrapping. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQN2Y1CVD3F27KT6XX0A6AY --- cmd/entire/cli/errors.go | 32 ++++++++++++++++++++ cmd/entire/cli/errors_test.go | 56 +++++++++++++++++++++++++++++++++++ cmd/entire/main.go | 7 ++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/errors_test.go diff --git a/cmd/entire/cli/errors.go b/cmd/entire/cli/errors.go index ada54cb70f..dfe08a046b 100644 --- a/cmd/entire/cli/errors.go +++ b/cmd/entire/cli/errors.go @@ -1,5 +1,13 @@ package cli +import ( + "fmt" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" +) + // SilentError wraps an error to signal that the error message has already been // printed to the user. main.go checks for this type to avoid duplicate output. type SilentError struct { @@ -25,3 +33,27 @@ func (e *SilentError) AlreadyPrinted() bool { func NewSilentError(err error) *SilentError { return &SilentError{Err: err} } + +// cliUpgradeRequiredCode is the OAuth error code the auth server returns +// when this CLI build is older than the minimum version it accepts. +const cliUpgradeRequiredCode = "cli_upgrade_required" + +// WithCLIUpgradeHint appends update guidance when the server rejected a +// request because this CLI build is too old. The server signals this with +// the cli_upgrade_required OAuth error code, which auth-go surfaces as an +// unrecognised-code error whose message carries the code verbatim — there is +// no sentinel to errors.Is against, so match on the code string. Without the +// hint the bare code gives the user no way forward; the appended command is +// the same one the periodic version check advertises. +// +// The code can surface from any auth-server OAuth flow — login (device and +// browser), the refresh grant under every authenticated command, and the +// RFC 8693 exchanges — so main.go applies this at its central error-print +// arm rather than any single command wrapping its own errors. +func WithCLIUpgradeHint(err error) error { + if err == nil || !strings.Contains(err.Error(), cliUpgradeRequiredCode) { + return err + } + return fmt.Errorf("%w\n\nThis version of the Entire CLI is too old for this server. Update it, then rerun the command:\n\n %s", + err, versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version)) +} diff --git a/cmd/entire/cli/errors_test.go b/cmd/entire/cli/errors_test.go new file mode 100644 index 0000000000..5d297fb47c --- /dev/null +++ b/cmd/entire/cli/errors_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "errors" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" +) + +func TestWithCLIUpgradeHint_AppendsUpdateCommand(t *testing.T) { + t.Parallel() + + base := errors.New("start login: start device auth: oauth error: cli_upgrade_required") + err := WithCLIUpgradeHint(base) + if !errors.Is(err, base) { + t.Fatalf("hinted error must wrap the original, got %v", err) + } + want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %q, want it to contain update command %q", err, want) + } +} + +func TestWithCLIUpgradeHint_RefreshFlowError(t *testing.T) { + t.Parallel() + + base := errors.New("refresh login token: oauth error: cli_upgrade_required") + err := WithCLIUpgradeHint(base) + want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %q, want it to contain update command %q", err, want) + } +} + +func TestWithCLIUpgradeHint_PassesThroughUnrelatedErrors(t *testing.T) { + t.Parallel() + + base := errors.New("start login: connection refused") + got := WithCLIUpgradeHint(base) + if !errors.Is(got, base) { + t.Fatalf("got %v, want it to wrap the original error", got) + } + if got.Error() != base.Error() { + t.Fatalf("got %q, want message unchanged %q", got, base) + } +} + +func TestWithCLIUpgradeHint_NilStaysNil(t *testing.T) { + t.Parallel() + + if got := WithCLIUpgradeHint(nil); got != nil { + t.Fatalf("got %v, want nil", got) + } +} diff --git a/cmd/entire/main.go b/cmd/entire/main.go index a658e7c923..ccdd1b441d 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -123,7 +123,12 @@ func main() { // deepest matched command, which is the one that failed. showSuggestion(executed, err) default: - fmt.Fprintln(rootCmd.OutOrStderr(), err) + // A server can reject any authenticated flow (login, token + // refresh, RFC 8693 exchange) because this build is too old; + // append the update command so the bare cli_upgrade_required + // code isn't a dead end. Applied here so every command gets + // the hint without wrapping its own errors. + fmt.Fprintln(rootCmd.OutOrStderr(), cli.WithCLIUpgradeHint(err)) } cancel() From ee32557ce77731b112fc4ff205ddc65176cfe521 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 11:27:46 +0200 Subject: [PATCH 2/8] feat: offer interactive CLI update on cli_upgrade_required Replace the appended-hint approach with a handler on main.go's error arm: on an interactive terminal, ask Yes/No and run the installer (same runner the version-check prompt uses, honoring ENTIRE_NO_AUTO_UPDATE); otherwise print the update command plus the failed command so the user can copy both. Guidance text: 'This Entire CLI version is no longer supported. Update it, then rerun the command:' Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQPEHCWP8C9DDJR8M5XR6CG --- cmd/entire/cli/errors.go | 32 ----- cmd/entire/cli/errors_test.go | 56 -------- cmd/entire/cli/upgrade_required.go | 141 ++++++++++++++++++ cmd/entire/cli/upgrade_required_test.go | 149 ++++++++++++++++++++ cmd/entire/cli/versioncheck/autoupdate.go | 13 ++ cmd/entire/cli/versioncheck/versioncheck.go | 7 + cmd/entire/main.go | 10 +- 7 files changed, 316 insertions(+), 92 deletions(-) delete mode 100644 cmd/entire/cli/errors_test.go create mode 100644 cmd/entire/cli/upgrade_required.go create mode 100644 cmd/entire/cli/upgrade_required_test.go diff --git a/cmd/entire/cli/errors.go b/cmd/entire/cli/errors.go index dfe08a046b..ada54cb70f 100644 --- a/cmd/entire/cli/errors.go +++ b/cmd/entire/cli/errors.go @@ -1,13 +1,5 @@ package cli -import ( - "fmt" - "strings" - - "github.com/entireio/cli/cmd/entire/cli/versioncheck" - "github.com/entireio/cli/cmd/entire/cli/versioninfo" -) - // SilentError wraps an error to signal that the error message has already been // printed to the user. main.go checks for this type to avoid duplicate output. type SilentError struct { @@ -33,27 +25,3 @@ func (e *SilentError) AlreadyPrinted() bool { func NewSilentError(err error) *SilentError { return &SilentError{Err: err} } - -// cliUpgradeRequiredCode is the OAuth error code the auth server returns -// when this CLI build is older than the minimum version it accepts. -const cliUpgradeRequiredCode = "cli_upgrade_required" - -// WithCLIUpgradeHint appends update guidance when the server rejected a -// request because this CLI build is too old. The server signals this with -// the cli_upgrade_required OAuth error code, which auth-go surfaces as an -// unrecognised-code error whose message carries the code verbatim — there is -// no sentinel to errors.Is against, so match on the code string. Without the -// hint the bare code gives the user no way forward; the appended command is -// the same one the periodic version check advertises. -// -// The code can surface from any auth-server OAuth flow — login (device and -// browser), the refresh grant under every authenticated command, and the -// RFC 8693 exchanges — so main.go applies this at its central error-print -// arm rather than any single command wrapping its own errors. -func WithCLIUpgradeHint(err error) error { - if err == nil || !strings.Contains(err.Error(), cliUpgradeRequiredCode) { - return err - } - return fmt.Errorf("%w\n\nThis version of the Entire CLI is too old for this server. Update it, then rerun the command:\n\n %s", - err, versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version)) -} diff --git a/cmd/entire/cli/errors_test.go b/cmd/entire/cli/errors_test.go deleted file mode 100644 index 5d297fb47c..0000000000 --- a/cmd/entire/cli/errors_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package cli - -import ( - "errors" - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/versioncheck" - "github.com/entireio/cli/cmd/entire/cli/versioninfo" -) - -func TestWithCLIUpgradeHint_AppendsUpdateCommand(t *testing.T) { - t.Parallel() - - base := errors.New("start login: start device auth: oauth error: cli_upgrade_required") - err := WithCLIUpgradeHint(base) - if !errors.Is(err, base) { - t.Fatalf("hinted error must wrap the original, got %v", err) - } - want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) - if !strings.Contains(err.Error(), want) { - t.Fatalf("err = %q, want it to contain update command %q", err, want) - } -} - -func TestWithCLIUpgradeHint_RefreshFlowError(t *testing.T) { - t.Parallel() - - base := errors.New("refresh login token: oauth error: cli_upgrade_required") - err := WithCLIUpgradeHint(base) - want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) - if !strings.Contains(err.Error(), want) { - t.Fatalf("err = %q, want it to contain update command %q", err, want) - } -} - -func TestWithCLIUpgradeHint_PassesThroughUnrelatedErrors(t *testing.T) { - t.Parallel() - - base := errors.New("start login: connection refused") - got := WithCLIUpgradeHint(base) - if !errors.Is(got, base) { - t.Fatalf("got %v, want it to wrap the original error", got) - } - if got.Error() != base.Error() { - t.Fatalf("got %q, want message unchanged %q", got, base) - } -} - -func TestWithCLIUpgradeHint_NilStaysNil(t *testing.T) { - t.Parallel() - - if got := WithCLIUpgradeHint(nil); got != nil { - t.Fatalf("got %v, want nil", got) - } -} diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go new file mode 100644 index 0000000000..5830f3812b --- /dev/null +++ b/cmd/entire/cli/upgrade_required.go @@ -0,0 +1,141 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "charm.land/huh/v2" + + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" +) + +// cliUpgradeRequiredCode is the OAuth error code the server returns when +// this CLI build is older than the minimum version it accepts. +const cliUpgradeRequiredCode = "cli_upgrade_required" + +// cliUnsupportedMsg leads every cli_upgrade_required guidance block; the +// two indented lines that follow are the update command and the command +// that failed, in the order the user should run them. +const cliUnsupportedMsg = "This Entire CLI version is no longer supported. Update it, then rerun the command:" + +// IsCLIUpgradeRequired reports whether err carries the server's +// cli_upgrade_required OAuth error code. auth-go surfaces the code as an +// unrecognised-code error whose message carries it verbatim — there is no +// sentinel to errors.Is against, so match on the code string. +func IsCLIUpgradeRequired(err error) bool { + return err != nil && strings.Contains(err.Error(), cliUpgradeRequiredCode) +} + +// upgradeOfferDeps carries the environment-dependent pieces of +// offerCLIUpgrade so tests can drive both branches without a TTY or a +// real installer. Zero-value fields are safe: nil confirm declines and +// nil runInstaller is never reached without confirm. +type upgradeOfferDeps struct { + canPrompt func() bool + confirm func(ctx context.Context, updateCmd string) (bool, error) + runInstaller func(ctx context.Context, cmdStr string) error +} + +// OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure after +// main.go has printed the error itself. On an interactive terminal it +// offers to run the updater right away (same installer the version-check +// prompt uses); otherwise — no TTY, ENTIRE_NO_AUTO_UPDATE set, declined, +// or no runnable installer for this platform — it prints the update +// command and the failed command so the user can run both. argv is the +// failed invocation's os.Args. No-op when err doesn't carry the code. +// +// The code can surface from any auth-server OAuth flow — login (device +// and browser), the refresh grant under every authenticated command, and +// the RFC 8693 exchanges — which is why this hangs off main.go's central +// error-print arm rather than any single command. +func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) { + offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ + canPrompt: func() bool { + return os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && + versioncheck.CanAutoInstall() && + interactive.CanPromptInteractively() && + interactive.IsTerminalWriter(w) + }, + confirm: confirmCLIUpgrade, + runInstaller: versioncheck.RunUpdateInstaller, + }) +} + +func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) { + if !IsCLIUpgradeRequired(err) { + return + } + updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + rerun := rerunCommandLine(argv) + + if !deps.canPrompt() { + printCLIUpgradeCommands(w, updateCmd, rerun) + return + } + + confirmed, confirmErr := deps.confirm(ctx, updateCmd) + if confirmErr != nil || !confirmed { + // Declined or the prompt itself failed/was aborted — leave the + // copyable commands either way. + printCLIUpgradeCommands(w, updateCmd, rerun) + return + } + + fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", updateCmd) + if installErr := deps.runInstaller(ctx, updateCmd); installErr != nil { + fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", installErr, updateCmd) + return + } + fmt.Fprintf(w, "Update complete. Rerun the command:\n\n %s\n", rerun) +} + +// printCLIUpgradeCommands prints the non-interactive guidance block: the +// update command followed by the command that failed, ready to copy. +func printCLIUpgradeCommands(w io.Writer, updateCmd, rerun string) { + fmt.Fprintf(w, "\n%s\n\n %s\n", cliUnsupportedMsg, updateCmd) + if rerun != "" { + fmt.Fprintf(w, " %s\n", rerun) + } +} + +// confirmCLIUpgrade renders the Yes/No update prompt. Abort (Ctrl-C, +// timeout) surfaces as an error and the caller falls back to printing +// the commands. +func confirmCLIUpgrade(ctx context.Context, updateCmd string) (bool, error) { + confirmed := true + form := NewAccessibleForm(huh.NewGroup( + huh.NewConfirm(). + Title("This Entire CLI version is no longer supported."). + Description(fmt.Sprintf("Update now? (runs `%s`)", updateCmd)). + Value(&confirmed), + )) + if err := form.RunWithContext(ctx); err != nil { + return false, fmt.Errorf("update prompt: %w", err) + } + return confirmed, nil +} + +// rerunCommandLine reconstructs the failed invocation for display: +// argv[0] reduced to its base name, arguments with shell-significant +// whitespace or quotes quoted so the line can be pasted back verbatim. +func rerunCommandLine(argv []string) string { + if len(argv) == 0 { + return "" + } + parts := make([]string, 0, len(argv)) + parts = append(parts, filepath.Base(argv[0])) + for _, a := range argv[1:] { + if strings.ContainsAny(a, " \t\"'") { + a = strconv.Quote(a) + } + parts = append(parts, a) + } + return strings.Join(parts, " ") +} diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go new file mode 100644 index 0000000000..b90afd8498 --- /dev/null +++ b/cmd/entire/cli/upgrade_required_test.go @@ -0,0 +1,149 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" +) + +func upgradeErr() error { + return errors.New("start login: start device auth: oauth error: cli_upgrade_required") +} + +func loginArgv() []string { + return []string{"/usr/local/bin/entire", "login", "--device"} +} + +func TestIsCLIUpgradeRequired(t *testing.T) { + t.Parallel() + + if !IsCLIUpgradeRequired(upgradeErr()) { + t.Error("want true for an error carrying cli_upgrade_required") + } + if IsCLIUpgradeRequired(errors.New("connection refused")) { + t.Error("want false for an unrelated error") + } + if IsCLIUpgradeRequired(nil) { + t.Error("want false for nil") + } +} + +func TestOfferCLIUpgrade_UnrelatedErrorPrintsNothing(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, errors.New("boom"), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { t.Error("canPrompt must not be consulted"); return false }, + }) + if out.Len() != 0 { + t.Fatalf("expected no output, got:\n%s", out.String()) + } +} + +func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { return false }, + }) + + got := out.String() + if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it, then rerun the command:") { + t.Errorf("missing unsupported-version message:\n%s", got) + } + if want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version); !strings.Contains(got, want) { + t.Errorf("missing update command %q:\n%s", want, got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing failed command to rerun:\n%s", got) + } +} + +func TestOfferCLIUpgrade_ConfirmYesRunsInstaller(t *testing.T) { + t.Parallel() + + var installed []string + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { return true }, + confirm: func(context.Context, string) (bool, error) { return true, nil }, + runInstaller: func(_ context.Context, cmdStr string) error { + installed = append(installed, cmdStr) + return nil + }, + }) + + want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + if len(installed) != 1 || installed[0] != want { + t.Fatalf("installer calls = %v, want exactly [%q]", installed, want) + } + got := out.String() + if !strings.Contains(got, "Update complete") { + t.Errorf("missing completion message:\n%s", got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing rerun command after update:\n%s", got) + } +} + +func TestOfferCLIUpgrade_ConfirmNoPrintsCommands(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { return true }, + confirm: func(context.Context, string) (bool, error) { return false, nil }, + runInstaller: func(context.Context, string) error { + t.Error("installer must not run when declined") + return nil + }, + }) + + got := out.String() + if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it, then rerun the command:") { + t.Errorf("missing unsupported-version message:\n%s", got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing failed command to rerun:\n%s", got) + } +} + +func TestOfferCLIUpgrade_InstallerFailurePrintsRetryCommand(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { return true }, + confirm: func(context.Context, string) (bool, error) { return true, nil }, + runInstaller: func(context.Context, string) error { return errors.New("brew exploded") }, + }) + + got := out.String() + if !strings.Contains(got, "Update failed") { + t.Errorf("missing failure message:\n%s", got) + } + if want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version); !strings.Contains(got, want) { + t.Errorf("missing retry command %q:\n%s", want, got) + } +} + +func TestRerunCommandLine(t *testing.T) { + t.Parallel() + + got := rerunCommandLine([]string{"/usr/local/bin/entire", "api", "/me", "--to", "cell"}) + if got != "entire api /me --to cell" { + t.Errorf("got %q, want %q", got, "entire api /me --to cell") + } + if got := rerunCommandLine([]string{"entire", "dispatch", "fix the thing"}); got != `entire dispatch "fix the thing"` { + t.Errorf("got %q, want quoted arg", got) + } + if got := rerunCommandLine(nil); got != "" { + t.Errorf("got %q, want empty for nil argv", got) + } +} diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go index d088689c3d..b1b2ad84ff 100644 --- a/cmd/entire/cli/versioncheck/autoupdate.go +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -19,6 +19,19 @@ import ( // envKillSwitch disables the interactive update prompt regardless of TTY. const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE" +// EnvNoAutoUpdate is the exported name of the kill switch, for callers +// outside this package that offer their own update prompts (the +// cli_upgrade_required flow in package cli) and must honor the same knob. +const EnvNoAutoUpdate = envKillSwitch + +// RunUpdateInstaller runs a self-update installer command returned by +// UpdateCommandForCurrentBinary, streaming output to the terminal. Shared +// by the interactive version-check prompt and the cli_upgrade_required +// flow in package cli. +func RunUpdateInstaller(ctx context.Context, cmdStr string) error { + return runInstaller(ctx, cmdStr) +} + // AutoUpdateAction describes the result of an update prompt. type AutoUpdateAction string diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index 0eed10c6d6..40c1ca5fe2 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -417,6 +417,13 @@ func UpdateCommandForCurrentBinary(currentVersion string) string { return updateCommand(currentVersion) } +// CanAutoInstall reports whether UpdateCommandForCurrentBinary returns a +// runnable installer command rather than a downloads-page URL, i.e. +// whether it is safe to offer executing it. +func CanAutoInstall() bool { + return canAutoInstall() +} + // printNotification prints the version update notification to the user. func printNotification(w io.Writer, current, latest string) { fmt.Fprintf(w, "\nUpdate available! %s -> %s\nRelease notes: %s\n", diff --git a/cmd/entire/main.go b/cmd/entire/main.go index ccdd1b441d..9bce17f635 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -123,12 +123,14 @@ func main() { // deepest matched command, which is the one that failed. showSuggestion(executed, err) default: + fmt.Fprintln(rootCmd.OutOrStderr(), err) // A server can reject any authenticated flow (login, token // refresh, RFC 8693 exchange) because this build is too old; - // append the update command so the bare cli_upgrade_required - // code isn't a dead end. Applied here so every command gets - // the hint without wrapping its own errors. - fmt.Fprintln(rootCmd.OutOrStderr(), cli.WithCLIUpgradeHint(err)) + // don't leave the bare cli_upgrade_required code as a dead + // end — offer the update interactively, or print the update + // and rerun commands. Handled here so every command gets it + // without wrapping its own errors. + cli.OfferCLIUpgradeIfRequired(ctx, rootCmd.OutOrStderr(), err, os.Args) } cancel() From 0241e458d7f6e5d139e882be14a7312385749c75 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 11:31:51 +0200 Subject: [PATCH 3/8] polish: split upgrade hint into update and rerun blocks Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQPP0NCWKY9G2ZBY8TE8Z0Z --- cmd/entire/cli/upgrade_required.go | 12 ++++++------ cmd/entire/cli/upgrade_required_test.go | 7 +++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 5830f3812b..0cf9633e19 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -20,10 +20,10 @@ import ( // this CLI build is older than the minimum version it accepts. const cliUpgradeRequiredCode = "cli_upgrade_required" -// cliUnsupportedMsg leads every cli_upgrade_required guidance block; the -// two indented lines that follow are the update command and the command -// that failed, in the order the user should run them. -const cliUnsupportedMsg = "This Entire CLI version is no longer supported. Update it, then rerun the command:" +// cliUnsupportedMsg leads every cli_upgrade_required guidance block, +// followed by the update command, then a "Then rerun the command:" block +// with the command that failed. +const cliUnsupportedMsg = "This Entire CLI version is no longer supported. Update it:" // IsCLIUpgradeRequired reports whether err carries the server's // cli_upgrade_required OAuth error code. auth-go surfaces the code as an @@ -97,11 +97,11 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, } // printCLIUpgradeCommands prints the non-interactive guidance block: the -// update command followed by the command that failed, ready to copy. +// update command, then the command that failed, each ready to copy. func printCLIUpgradeCommands(w io.Writer, updateCmd, rerun string) { fmt.Fprintf(w, "\n%s\n\n %s\n", cliUnsupportedMsg, updateCmd) if rerun != "" { - fmt.Fprintf(w, " %s\n", rerun) + fmt.Fprintf(w, "\nThen rerun the command:\n\n %s\n", rerun) } } diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index b90afd8498..2a2a11d3d6 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -54,12 +54,15 @@ func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T }) got := out.String() - if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it, then rerun the command:") { + if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it:") { t.Errorf("missing unsupported-version message:\n%s", got) } if want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version); !strings.Contains(got, want) { t.Errorf("missing update command %q:\n%s", want, got) } + if !strings.Contains(got, "Then rerun the command:") { + t.Errorf("missing rerun label:\n%s", got) + } if !strings.Contains(got, "entire login --device") { t.Errorf("missing failed command to rerun:\n%s", got) } @@ -106,7 +109,7 @@ func TestOfferCLIUpgrade_ConfirmNoPrintsCommands(t *testing.T) { }) got := out.String() - if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it, then rerun the command:") { + if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it:") { t.Errorf("missing unsupported-version message:\n%s", got) } if !strings.Contains(got, "entire login --device") { From 51700be79f6836ab7d50ce75491545462f832c67 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 11:36:10 +0200 Subject: [PATCH 4/8] polish: suppress raw cli_upgrade_required error, show guidance only The wrapped OAuth chain adds nothing the guidance block doesn't say; the handler now replaces the raw print (SilentError-style) instead of following it. Exit code stays 1. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQPXWZFY96KNZMVB2555ZHN --- cmd/entire/cli/upgrade_required.go | 35 ++++++++++++++----------- cmd/entire/cli/upgrade_required_test.go | 10 +++++-- cmd/entire/main.go | 15 ++++++----- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 0cf9633e19..55f412275f 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -43,20 +43,22 @@ type upgradeOfferDeps struct { runInstaller func(ctx context.Context, cmdStr string) error } -// OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure after -// main.go has printed the error itself. On an interactive terminal it -// offers to run the updater right away (same installer the version-check -// prompt uses); otherwise — no TTY, ENTIRE_NO_AUTO_UPDATE set, declined, -// or no runnable installer for this platform — it prints the update -// command and the failed command so the user can run both. argv is the -// failed invocation's os.Args. No-op when err doesn't carry the code. +// OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure in +// place of the raw error print: it reports true when it took over the +// messaging (main.go then skips printing err — the raw OAuth chain adds +// nothing the guidance doesn't say), false when err doesn't carry the +// code. On an interactive terminal it offers to run the updater right +// away (same installer the version-check prompt uses); otherwise — no +// TTY, ENTIRE_NO_AUTO_UPDATE set, declined, or no runnable installer for +// this platform — it prints the update command and the failed command so +// the user can run both. argv is the failed invocation's os.Args. // // The code can surface from any auth-server OAuth flow — login (device // and browser), the refresh grant under every authenticated command, and // the RFC 8693 exchanges — which is why this hangs off main.go's central // error-print arm rather than any single command. -func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) { - offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ +func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) bool { + return offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ canPrompt: func() bool { return os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && versioncheck.CanAutoInstall() && @@ -68,16 +70,16 @@ func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv }) } -func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) { +func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) bool { if !IsCLIUpgradeRequired(err) { - return + return false } updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) rerun := rerunCommandLine(argv) if !deps.canPrompt() { printCLIUpgradeCommands(w, updateCmd, rerun) - return + return true } confirmed, confirmErr := deps.confirm(ctx, updateCmd) @@ -85,21 +87,22 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, // Declined or the prompt itself failed/was aborted — leave the // copyable commands either way. printCLIUpgradeCommands(w, updateCmd, rerun) - return + return true } - fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", updateCmd) + fmt.Fprintf(w, "Updating Entire CLI: %s\n", updateCmd) if installErr := deps.runInstaller(ctx, updateCmd); installErr != nil { fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", installErr, updateCmd) - return + return true } fmt.Fprintf(w, "Update complete. Rerun the command:\n\n %s\n", rerun) + return true } // printCLIUpgradeCommands prints the non-interactive guidance block: the // update command, then the command that failed, each ready to copy. func printCLIUpgradeCommands(w io.Writer, updateCmd, rerun string) { - fmt.Fprintf(w, "\n%s\n\n %s\n", cliUnsupportedMsg, updateCmd) + fmt.Fprintf(w, "%s\n\n %s\n", cliUnsupportedMsg, updateCmd) if rerun != "" { fmt.Fprintf(w, "\nThen rerun the command:\n\n %s\n", rerun) } diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index 2a2a11d3d6..a5d83abe14 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -37,9 +37,12 @@ func TestOfferCLIUpgrade_UnrelatedErrorPrintsNothing(t *testing.T) { t.Parallel() var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, errors.New("boom"), loginArgv(), upgradeOfferDeps{ + handled := offerCLIUpgrade(context.Background(), &out, errors.New("boom"), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { t.Error("canPrompt must not be consulted"); return false }, }) + if handled { + t.Error("handled = true, want false so main.go prints the error itself") + } if out.Len() != 0 { t.Fatalf("expected no output, got:\n%s", out.String()) } @@ -49,10 +52,13 @@ func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T t.Parallel() var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + handled := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return false }, }) + if !handled { + t.Error("handled = false, want true so main.go skips the raw error") + } got := out.String() if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it:") { t.Errorf("missing unsupported-version message:\n%s", got) diff --git a/cmd/entire/main.go b/cmd/entire/main.go index 9bce17f635..a99a67140e 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -123,14 +123,15 @@ func main() { // deepest matched command, which is the one that failed. showSuggestion(executed, err) default: - fmt.Fprintln(rootCmd.OutOrStderr(), err) // A server can reject any authenticated flow (login, token - // refresh, RFC 8693 exchange) because this build is too old; - // don't leave the bare cli_upgrade_required code as a dead - // end — offer the update interactively, or print the update - // and rerun commands. Handled here so every command gets it - // without wrapping its own errors. - cli.OfferCLIUpgradeIfRequired(ctx, rootCmd.OutOrStderr(), err, os.Args) + // refresh, RFC 8693 exchange) because this build is too old. + // The handler replaces the raw cli_upgrade_required error with + // actionable guidance — an interactive update offer, or the + // update + rerun commands. Handled here so every command gets + // it without wrapping its own errors. + if !cli.OfferCLIUpgradeIfRequired(ctx, rootCmd.OutOrStderr(), err, os.Args) { + fmt.Fprintln(rootCmd.OutOrStderr(), err) + } } cancel() From 4cd2a1f707eea1470a21da85360d159ec76067ce Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 11:55:06 +0200 Subject: [PATCH 5/8] feat: rerun the original command after a successful update After the installer succeeds, re-resolve argv[0] (picking up the fresh binary) and spawn the original invocation with the terminal attached, exiting with the child's code. ENTIRE_UPGRADE_RERUN guards the loop where the installer updated a different binary than the running one: the rerun then prints the commands instead of prompting again. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQR0JH5CCDRA8V6K1AE42SQ --- cmd/entire/cli/upgrade_required.go | 84 ++++++++++++++++++++----- cmd/entire/cli/upgrade_required_test.go | 39 +++++++++++- 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 55f412275f..3946cc320d 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -2,9 +2,11 @@ package cli import ( "context" + "errors" "fmt" "io" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -33,14 +35,23 @@ func IsCLIUpgradeRequired(err error) bool { return err != nil && strings.Contains(err.Error(), cliUpgradeRequiredCode) } +// envUpgradeRerun marks a process re-executed after a successful +// cli_upgrade_required update. If that rerun fails the same way (the +// installer updated a different binary than the one running), the prompt +// is suppressed and the commands are printed instead — otherwise an +// ineffective update would loop forever. +const envUpgradeRerun = "ENTIRE_UPGRADE_RERUN" + // upgradeOfferDeps carries the environment-dependent pieces of -// offerCLIUpgrade so tests can drive both branches without a TTY or a -// real installer. Zero-value fields are safe: nil confirm declines and -// nil runInstaller is never reached without confirm. +// offerCLIUpgrade so tests can drive both branches without a TTY, a +// real installer, or replacing the test process. Zero-value fields are +// safe: nil confirm declines, and nil runInstaller/reexec are never +// reached without confirm. type upgradeOfferDeps struct { canPrompt func() bool confirm func(ctx context.Context, updateCmd string) (bool, error) runInstaller func(ctx context.Context, cmdStr string) error + reexec func(ctx context.Context, argv []string) error } // OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure in @@ -48,10 +59,12 @@ type upgradeOfferDeps struct { // messaging (main.go then skips printing err — the raw OAuth chain adds // nothing the guidance doesn't say), false when err doesn't carry the // code. On an interactive terminal it offers to run the updater right -// away (same installer the version-check prompt uses); otherwise — no -// TTY, ENTIRE_NO_AUTO_UPDATE set, declined, or no runnable installer for -// this platform — it prints the update command and the failed command so -// the user can run both. argv is the failed invocation's os.Args. +// away (same installer the version-check prompt uses) and, on success, +// re-executes the original command with the freshly installed binary; +// otherwise — no TTY, ENTIRE_NO_AUTO_UPDATE set, declined, or no +// runnable installer for this platform — it prints the update command +// and the failed command so the user can run both. argv is the failed +// invocation's os.Args. // // The code can surface from any auth-server OAuth flow — login (device // and browser), the refresh grant under every authenticated command, and @@ -59,17 +72,25 @@ type upgradeOfferDeps struct { // error-print arm rather than any single command. func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) bool { return offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ - canPrompt: func() bool { - return os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && - versioncheck.CanAutoInstall() && - interactive.CanPromptInteractively() && - interactive.IsTerminalWriter(w) - }, + canPrompt: func() bool { return upgradePromptAllowed(w) }, confirm: confirmCLIUpgrade, runInstaller: versioncheck.RunUpdateInstaller, + reexec: reexecCommand, }) } +// upgradePromptAllowed reports whether the interactive update offer may +// be shown: never on a post-update rerun (loop guard), never with the +// auto-update kill switch set, and only with a runnable installer on an +// interactive terminal. +func upgradePromptAllowed(w io.Writer) bool { + return os.Getenv(envUpgradeRerun) == "" && + os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && + versioncheck.CanAutoInstall() && + interactive.CanPromptInteractively() && + interactive.IsTerminalWriter(w) +} + func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) bool { if !IsCLIUpgradeRequired(err) { return false @@ -95,10 +116,45 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", installErr, updateCmd) return true } - fmt.Fprintf(w, "Update complete. Rerun the command:\n\n %s\n", rerun) + fmt.Fprintf(w, "Update complete. Rerunning the command:\n\n %s\n\n", rerun) + if execErr := deps.reexec(ctx, argv); execErr != nil { + fmt.Fprintf(w, "Could not rerun automatically (%v). Rerun the command:\n\n %s\n", execErr, rerun) + } return true } +// reexecCommand reruns the original invocation with the freshly +// installed binary: argv[0] is re-resolved (through PATH when bare, or +// the same path the installer just replaced) and the child inherits the +// terminal, so interactive flows like the login prompts still work. The +// envUpgradeRerun marker suppresses a second update offer if the rerun +// fails the same way. On success this does not return — the process +// exits with the child's exit code. A spawn-and-exit is used instead of +// syscall.Exec so the one code path covers Windows too. +func reexecCommand(ctx context.Context, argv []string) error { + if len(argv) == 0 { + return errors.New("original command unknown") + } + path, err := exec.LookPath(argv[0]) + if err != nil { + return fmt.Errorf("locate %s: %w", argv[0], err) + } + cmd := exec.CommandContext(ctx, path, argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), envUpgradeRerun+"=1") + if runErr := cmd.Run(); runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("rerun command: %w", runErr) + } + os.Exit(0) + return nil +} + // printCLIUpgradeCommands prints the non-interactive guidance block: the // update command, then the command that failed, each ready to copy. func printCLIUpgradeCommands(w io.Writer, updateCmd, rerun string) { diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index a5d83abe14..e35a225468 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -74,10 +74,11 @@ func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T } } -func TestOfferCLIUpgrade_ConfirmYesRunsInstaller(t *testing.T) { +func TestOfferCLIUpgrade_ConfirmYesRunsInstallerAndRerunsCommand(t *testing.T) { t.Parallel() var installed []string + var rerunArgv [][]string var out bytes.Buffer offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return true }, @@ -86,18 +87,52 @@ func TestOfferCLIUpgrade_ConfirmYesRunsInstaller(t *testing.T) { installed = append(installed, cmdStr) return nil }, + reexec: func(_ context.Context, argv []string) error { + rerunArgv = append(rerunArgv, argv) + return nil + }, }) want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) if len(installed) != 1 || installed[0] != want { t.Fatalf("installer calls = %v, want exactly [%q]", installed, want) } + if len(rerunArgv) != 1 || strings.Join(rerunArgv[0], " ") != strings.Join(loginArgv(), " ") { + t.Fatalf("reexec argv = %v, want the original invocation %v", rerunArgv, loginArgv()) + } got := out.String() if !strings.Contains(got, "Update complete") { t.Errorf("missing completion message:\n%s", got) } if !strings.Contains(got, "entire login --device") { - t.Errorf("missing rerun command after update:\n%s", got) + t.Errorf("missing rerun command line:\n%s", got) + } +} + +func TestOfferCLIUpgrade_ReexecFailurePrintsRerunCommand(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { return true }, + confirm: func(context.Context, string) (bool, error) { return true, nil }, + runInstaller: func(context.Context, string) error { return nil }, + reexec: func(context.Context, []string) error { return errors.New("exec format error") }, + }) + + got := out.String() + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing manual rerun command after failed re-exec:\n%s", got) + } +} + +func TestUpgradePromptAllowed_RerunGuardBlocksSecondPrompt(t *testing.T) { + // t.Setenv forbids t.Parallel. + t.Setenv(envUpgradeRerun, "1") + t.Setenv("ENTIRE_TEST_TTY", "1") // force the interactive probe on; the guard must still win + + if upgradePromptAllowed(&bytes.Buffer{}) { + t.Error("prompt must be suppressed on a post-update rerun to avoid an update loop") } } From a2ede890511c43bfe2cf0887775dafb0188fe74d Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 12:02:26 +0200 Subject: [PATCH 6/8] polish: explain stale binary on post-update rerun failure Telling the user to update again right after a successful update is wrong; the rerun child now names the binary it ran and points at PATH. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQRE088AFVQZ2Q64H3EN6FD --- cmd/entire/cli/upgrade_required.go | 22 ++++++++++++++++----- cmd/entire/cli/upgrade_required_test.go | 26 +++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 3946cc320d..2e0cdb6722 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -80,12 +80,11 @@ func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv } // upgradePromptAllowed reports whether the interactive update offer may -// be shown: never on a post-update rerun (loop guard), never with the -// auto-update kill switch set, and only with a runnable installer on an -// interactive terminal. +// be shown: never with the auto-update kill switch set, and only with a +// runnable installer on an interactive terminal. (The post-update rerun +// loop guard short-circuits in offerCLIUpgrade before this is consulted.) func upgradePromptAllowed(w io.Writer) bool { - return os.Getenv(envUpgradeRerun) == "" && - os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && + return os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && versioncheck.CanAutoInstall() && interactive.CanPromptInteractively() && interactive.IsTerminalWriter(w) @@ -98,6 +97,19 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) rerun := rerunCommandLine(argv) + // Post-update rerun still rejected: the installer wrote somewhere + // other than the binary this invocation ran (e.g. a dev build outside + // the install manager's path). Saying "update it" again would be + // wrong — the update already happened; name the actual problem. + if os.Getenv(envUpgradeRerun) != "" { + binary := "this binary" + if len(argv) > 0 { + binary = argv[0] + } + fmt.Fprintf(w, "The update installed, but this command still ran an unsupported binary (%s).\nCheck that `entire` on your PATH points at the updated install, then rerun:\n\n %s\n", binary, rerun) + return true + } + if !deps.canPrompt() { printCLIUpgradeCommands(w, updateCmd, rerun) return true diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index e35a225468..45eb72e8c6 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -126,13 +126,31 @@ func TestOfferCLIUpgrade_ReexecFailurePrintsRerunCommand(t *testing.T) { } } -func TestUpgradePromptAllowed_RerunGuardBlocksSecondPrompt(t *testing.T) { +func TestOfferCLIUpgrade_RerunGuardExplainsStaleBinary(t *testing.T) { // t.Setenv forbids t.Parallel. t.Setenv(envUpgradeRerun, "1") - t.Setenv("ENTIRE_TEST_TTY", "1") // force the interactive probe on; the guard must still win - if upgradePromptAllowed(&bytes.Buffer{}) { - t.Error("prompt must be suppressed on a post-update rerun to avoid an update loop") + var out bytes.Buffer + handled := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + canPrompt: func() bool { t.Error("prompt must be suppressed on a post-update rerun"); return true }, + runInstaller: func(context.Context, string) error { + t.Error("installer must not run again on a post-update rerun") + return nil + }, + }) + + if !handled { + t.Error("handled = false, want true") + } + got := out.String() + if !strings.Contains(got, "still ran an unsupported binary (/usr/local/bin/entire)") { + t.Errorf("missing stale-binary explanation naming the binary:\n%s", got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing rerun command:\n%s", got) + } + if strings.Contains(got, cliUnsupportedMsg) { + t.Errorf("must not tell the user to update again right after updating:\n%s", got) } } From 2f89deb4f8b9ec41634539f280bc7eb355074d4e Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 17 Jul 2026 13:12:32 +0200 Subject: [PATCH 7/8] refactor: unify both update offers onto one shared flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version-check prompt and the cli_upgrade_required handler each carried their own prompt gate and installer-run block, so the two could drift. Move the gate (PromptAllowed) and the accepted-update tail (RunUpdate: installer, messages, optional re-exec with loop guard) into versioncheck/updateflow.go; the triggers now differ only in detection and prompt shape. Version check passes nil argv — it fires after the command already succeeded, so rerunning would execute it twice — while cli_upgrade_required retries the failed command. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXQWEBJM04P1607TDM5A9ZEK --- cmd/entire/cli/upgrade_required.go | 120 ++---------- cmd/entire/cli/upgrade_required_test.go | 98 ++-------- cmd/entire/cli/versioncheck/autoupdate.go | 24 +-- cmd/entire/cli/versioncheck/updateflow.go | 125 +++++++++++++ .../cli/versioncheck/updateflow_test.go | 175 ++++++++++++++++++ cmd/entire/cli/versioncheck/versioncheck.go | 7 - 6 files changed, 343 insertions(+), 206 deletions(-) create mode 100644 cmd/entire/cli/versioncheck/updateflow.go create mode 100644 cmd/entire/cli/versioncheck/updateflow_test.go diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 2e0cdb6722..2500ab531d 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -2,18 +2,12 @@ package cli import ( "context" - "errors" "fmt" "io" - "os" - "os/exec" - "path/filepath" - "strconv" "strings" "charm.land/huh/v2" - "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/versioncheck" "github.com/entireio/cli/cmd/entire/cli/versioninfo" ) @@ -35,23 +29,15 @@ func IsCLIUpgradeRequired(err error) bool { return err != nil && strings.Contains(err.Error(), cliUpgradeRequiredCode) } -// envUpgradeRerun marks a process re-executed after a successful -// cli_upgrade_required update. If that rerun fails the same way (the -// installer updated a different binary than the one running), the prompt -// is suppressed and the commands are printed instead — otherwise an -// ineffective update would loop forever. -const envUpgradeRerun = "ENTIRE_UPGRADE_RERUN" - // upgradeOfferDeps carries the environment-dependent pieces of // offerCLIUpgrade so tests can drive both branches without a TTY, a // real installer, or replacing the test process. Zero-value fields are -// safe: nil confirm declines, and nil runInstaller/reexec are never -// reached without confirm. +// safe: nil confirm declines, and nil runUpdate is never reached +// without confirm. type upgradeOfferDeps struct { - canPrompt func() bool - confirm func(ctx context.Context, updateCmd string) (bool, error) - runInstaller func(ctx context.Context, cmdStr string) error - reexec func(ctx context.Context, argv []string) error + canPrompt func() bool + confirm func(ctx context.Context, updateCmd string) (bool, error) + runUpdate func(ctx context.Context, w io.Writer, cmdStr string, argv []string) } // OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure in @@ -59,12 +45,12 @@ type upgradeOfferDeps struct { // messaging (main.go then skips printing err — the raw OAuth chain adds // nothing the guidance doesn't say), false when err doesn't carry the // code. On an interactive terminal it offers to run the updater right -// away (same installer the version-check prompt uses) and, on success, -// re-executes the original command with the freshly installed binary; -// otherwise — no TTY, ENTIRE_NO_AUTO_UPDATE set, declined, or no -// runnable installer for this platform — it prints the update command -// and the failed command so the user can run both. argv is the failed -// invocation's os.Args. +// away and, on success, re-executes the original command with the +// freshly installed binary (the shared versioncheck.RunUpdate tail, also +// used by the version-check prompt); otherwise — no TTY, +// ENTIRE_NO_AUTO_UPDATE set, declined, or no runnable installer for this +// platform — it prints the update command and the failed command so the +// user can run both. argv is the failed invocation's os.Args. // // The code can surface from any auth-server OAuth flow — login (device // and browser), the refresh grant under every authenticated command, and @@ -72,36 +58,26 @@ type upgradeOfferDeps struct { // error-print arm rather than any single command. func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) bool { return offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ - canPrompt: func() bool { return upgradePromptAllowed(w) }, - confirm: confirmCLIUpgrade, - runInstaller: versioncheck.RunUpdateInstaller, - reexec: reexecCommand, + canPrompt: func() bool { return versioncheck.PromptAllowed(w) }, + confirm: confirmCLIUpgrade, + runUpdate: versioncheck.RunUpdate, }) } -// upgradePromptAllowed reports whether the interactive update offer may -// be shown: never with the auto-update kill switch set, and only with a -// runnable installer on an interactive terminal. (The post-update rerun -// loop guard short-circuits in offerCLIUpgrade before this is consulted.) -func upgradePromptAllowed(w io.Writer) bool { - return os.Getenv(versioncheck.EnvNoAutoUpdate) == "" && - versioncheck.CanAutoInstall() && - interactive.CanPromptInteractively() && - interactive.IsTerminalWriter(w) -} - func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) bool { if !IsCLIUpgradeRequired(err) { return false } updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) - rerun := rerunCommandLine(argv) + rerun := versioncheck.RerunCommandLine(argv) // Post-update rerun still rejected: the installer wrote somewhere // other than the binary this invocation ran (e.g. a dev build outside // the install manager's path). Saying "update it" again would be // wrong — the update already happened; name the actual problem. - if os.Getenv(envUpgradeRerun) != "" { + // (PromptAllowed also blocks on this marker, but this branch replaces + // the whole guidance block, not just the prompt.) + if versioncheck.IsPostUpdateRerun() { binary := "this binary" if len(argv) > 0 { binary = argv[0] @@ -123,50 +99,10 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, return true } - fmt.Fprintf(w, "Updating Entire CLI: %s\n", updateCmd) - if installErr := deps.runInstaller(ctx, updateCmd); installErr != nil { - fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", installErr, updateCmd) - return true - } - fmt.Fprintf(w, "Update complete. Rerunning the command:\n\n %s\n\n", rerun) - if execErr := deps.reexec(ctx, argv); execErr != nil { - fmt.Fprintf(w, "Could not rerun automatically (%v). Rerun the command:\n\n %s\n", execErr, rerun) - } + deps.runUpdate(ctx, w, updateCmd, argv) return true } -// reexecCommand reruns the original invocation with the freshly -// installed binary: argv[0] is re-resolved (through PATH when bare, or -// the same path the installer just replaced) and the child inherits the -// terminal, so interactive flows like the login prompts still work. The -// envUpgradeRerun marker suppresses a second update offer if the rerun -// fails the same way. On success this does not return — the process -// exits with the child's exit code. A spawn-and-exit is used instead of -// syscall.Exec so the one code path covers Windows too. -func reexecCommand(ctx context.Context, argv []string) error { - if len(argv) == 0 { - return errors.New("original command unknown") - } - path, err := exec.LookPath(argv[0]) - if err != nil { - return fmt.Errorf("locate %s: %w", argv[0], err) - } - cmd := exec.CommandContext(ctx, path, argv[1:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), envUpgradeRerun+"=1") - if runErr := cmd.Run(); runErr != nil { - var exitErr *exec.ExitError - if errors.As(runErr, &exitErr) { - os.Exit(exitErr.ExitCode()) - } - return fmt.Errorf("rerun command: %w", runErr) - } - os.Exit(0) - return nil -} - // printCLIUpgradeCommands prints the non-interactive guidance block: the // update command, then the command that failed, each ready to copy. func printCLIUpgradeCommands(w io.Writer, updateCmd, rerun string) { @@ -192,21 +128,3 @@ func confirmCLIUpgrade(ctx context.Context, updateCmd string) (bool, error) { } return confirmed, nil } - -// rerunCommandLine reconstructs the failed invocation for display: -// argv[0] reduced to its base name, arguments with shell-significant -// whitespace or quotes quoted so the line can be pasted back verbatim. -func rerunCommandLine(argv []string) string { - if len(argv) == 0 { - return "" - } - parts := make([]string, 0, len(argv)) - parts = append(parts, filepath.Base(argv[0])) - for _, a := range argv[1:] { - if strings.ContainsAny(a, " \t\"'") { - a = strconv.Quote(a) - } - parts = append(parts, a) - } - return strings.Join(parts, " ") -} diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index 45eb72e8c6..e77c59ae6a 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "io" "strings" "testing" @@ -74,68 +75,44 @@ func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T } } -func TestOfferCLIUpgrade_ConfirmYesRunsInstallerAndRerunsCommand(t *testing.T) { +func TestOfferCLIUpgrade_ConfirmYesRunsSharedUpdateFlow(t *testing.T) { t.Parallel() - var installed []string - var rerunArgv [][]string + type updateCall struct { + cmdStr string + argv []string + } + var calls []updateCall var out bytes.Buffer offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return true }, confirm: func(context.Context, string) (bool, error) { return true, nil }, - runInstaller: func(_ context.Context, cmdStr string) error { - installed = append(installed, cmdStr) - return nil - }, - reexec: func(_ context.Context, argv []string) error { - rerunArgv = append(rerunArgv, argv) - return nil + runUpdate: func(_ context.Context, _ io.Writer, cmdStr string, argv []string) { + calls = append(calls, updateCall{cmdStr: cmdStr, argv: argv}) }, }) want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) - if len(installed) != 1 || installed[0] != want { - t.Fatalf("installer calls = %v, want exactly [%q]", installed, want) + if len(calls) != 1 { + t.Fatalf("runUpdate calls = %d, want exactly 1", len(calls)) } - if len(rerunArgv) != 1 || strings.Join(rerunArgv[0], " ") != strings.Join(loginArgv(), " ") { - t.Fatalf("reexec argv = %v, want the original invocation %v", rerunArgv, loginArgv()) + if calls[0].cmdStr != want { + t.Errorf("runUpdate command = %q, want %q", calls[0].cmdStr, want) } - got := out.String() - if !strings.Contains(got, "Update complete") { - t.Errorf("missing completion message:\n%s", got) - } - if !strings.Contains(got, "entire login --device") { - t.Errorf("missing rerun command line:\n%s", got) - } -} - -func TestOfferCLIUpgrade_ReexecFailurePrintsRerunCommand(t *testing.T) { - t.Parallel() - - var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ - canPrompt: func() bool { return true }, - confirm: func(context.Context, string) (bool, error) { return true, nil }, - runInstaller: func(context.Context, string) error { return nil }, - reexec: func(context.Context, []string) error { return errors.New("exec format error") }, - }) - - got := out.String() - if !strings.Contains(got, "entire login --device") { - t.Errorf("missing manual rerun command after failed re-exec:\n%s", got) + if strings.Join(calls[0].argv, " ") != strings.Join(loginArgv(), " ") { + t.Errorf("runUpdate argv = %v, want the original invocation %v", calls[0].argv, loginArgv()) } } func TestOfferCLIUpgrade_RerunGuardExplainsStaleBinary(t *testing.T) { // t.Setenv forbids t.Parallel. - t.Setenv(envUpgradeRerun, "1") + t.Setenv("ENTIRE_UPGRADE_RERUN", "1") var out bytes.Buffer handled := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { t.Error("prompt must be suppressed on a post-update rerun"); return true }, - runInstaller: func(context.Context, string) error { - t.Error("installer must not run again on a post-update rerun") - return nil + runUpdate: func(context.Context, io.Writer, string, []string) { + t.Error("update must not run again on a post-update rerun") }, }) @@ -161,9 +138,8 @@ func TestOfferCLIUpgrade_ConfirmNoPrintsCommands(t *testing.T) { offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return true }, confirm: func(context.Context, string) (bool, error) { return false, nil }, - runInstaller: func(context.Context, string) error { - t.Error("installer must not run when declined") - return nil + runUpdate: func(context.Context, io.Writer, string, []string) { + t.Error("update must not run when declined") }, }) @@ -175,37 +151,3 @@ func TestOfferCLIUpgrade_ConfirmNoPrintsCommands(t *testing.T) { t.Errorf("missing failed command to rerun:\n%s", got) } } - -func TestOfferCLIUpgrade_InstallerFailurePrintsRetryCommand(t *testing.T) { - t.Parallel() - - var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ - canPrompt: func() bool { return true }, - confirm: func(context.Context, string) (bool, error) { return true, nil }, - runInstaller: func(context.Context, string) error { return errors.New("brew exploded") }, - }) - - got := out.String() - if !strings.Contains(got, "Update failed") { - t.Errorf("missing failure message:\n%s", got) - } - if want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version); !strings.Contains(got, want) { - t.Errorf("missing retry command %q:\n%s", want, got) - } -} - -func TestRerunCommandLine(t *testing.T) { - t.Parallel() - - got := rerunCommandLine([]string{"/usr/local/bin/entire", "api", "/me", "--to", "cell"}) - if got != "entire api /me --to cell" { - t.Errorf("got %q, want %q", got, "entire api /me --to cell") - } - if got := rerunCommandLine([]string{"entire", "dispatch", "fix the thing"}); got != `entire dispatch "fix the thing"` { - t.Errorf("got %q, want quoted arg", got) - } - if got := rerunCommandLine(nil); got != "" { - t.Errorf("got %q, want empty for nil argv", got) - } -} diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go index b1b2ad84ff..1c03723133 100644 --- a/cmd/entire/cli/versioncheck/autoupdate.go +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -19,19 +19,6 @@ import ( // envKillSwitch disables the interactive update prompt regardless of TTY. const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE" -// EnvNoAutoUpdate is the exported name of the kill switch, for callers -// outside this package that offer their own update prompts (the -// cli_upgrade_required flow in package cli) and must honor the same knob. -const EnvNoAutoUpdate = envKillSwitch - -// RunUpdateInstaller runs a self-update installer command returned by -// UpdateCommandForCurrentBinary, streaming output to the terminal. Shared -// by the interactive version-check prompt and the cli_upgrade_required -// flow in package cli. -func RunUpdateInstaller(ctx context.Context, cmdStr string) error { - return runInstaller(ctx, cmdStr) -} - // AutoUpdateAction describes the result of an update prompt. type AutoUpdateAction string @@ -83,7 +70,7 @@ func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVer cmdStr := updateCommand(currentVersion) - if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) { + if !PromptAllowed(w) { printNotification(w, currentVersion, latestVersion) fmt.Fprintf(w, "To update, run:\n %s\n", cmdStr) return autoUpdateActionSkip @@ -97,12 +84,9 @@ func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVer switch action { case autoUpdateActionUpdate: - fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr) - if err := runInstaller(ctx, cmdStr); err != nil { - fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", err, cmdStr) - return autoUpdateActionUpdate - } - fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") + // nil argv: this trigger fires after the command already + // completed (PersistentPostRun), so there is nothing to rerun. + RunUpdate(ctx, w, cmdStr, nil) return autoUpdateActionUpdate case autoUpdateActionSkipUntilNextVersion: return autoUpdateActionSkipUntilNextVersion diff --git a/cmd/entire/cli/versioncheck/updateflow.go b/cmd/entire/cli/versioncheck/updateflow.go new file mode 100644 index 0000000000..242e875c7c --- /dev/null +++ b/cmd/entire/cli/versioncheck/updateflow.go @@ -0,0 +1,125 @@ +package versioncheck + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/interactive" +) + +// This file is the single update flow shared by both update triggers: +// the version-check nag (MaybeAutoUpdate) and the server's +// cli_upgrade_required rejection (package cli). The triggers differ only +// in how they detect the condition and which prompt they show — the gate +// (PromptAllowed) and everything after the user accepts (RunUpdate) live +// here once. + +// envUpgradeRerun marks a process re-executed after a successful update. +// If that rerun still fails or still looks outdated (the installer updated +// a different binary than the one running), prompts are suppressed so an +// ineffective update can't loop forever. +const envUpgradeRerun = "ENTIRE_UPGRADE_RERUN" + +// reexec is a test seam for re-executing the original command. +var reexec = realReexecCommand + +// IsPostUpdateRerun reports whether this process was re-executed by +// RunUpdate after an update. Callers use it to replace "update it" advice +// with a stale-binary explanation. +func IsPostUpdateRerun() bool { + return os.Getenv(envUpgradeRerun) != "" +} + +// PromptAllowed is the single gate for showing an interactive update +// prompt, shared by both triggers: never with the kill switch set or on a +// post-update rerun (loop guard), and only with a runnable installer on +// an interactive terminal. +func PromptAllowed(w io.Writer) bool { + return os.Getenv(envKillSwitch) == "" && + !IsPostUpdateRerun() && + canAutoInstall() && + interactive.CanPromptInteractively() && + isTerminalOut(w) +} + +// RunUpdate is the shared execution tail after the user accepts an update +// offer: it announces the installer command, runs it, and reports failure +// with a copyable retry command. On success, a nil argv just tells the +// user to re-run entire (the version-check trigger fires after the +// command already completed — re-executing would run it twice); a non-nil +// argv is re-executed with the freshly installed binary (the +// cli_upgrade_required trigger fires after the command failed, so the +// rerun is a retry). On a successful re-exec the process exits with the +// child's exit code and this function does not return. +func RunUpdate(ctx context.Context, w io.Writer, cmdStr string, argv []string) { + fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr) + if err := runInstaller(ctx, cmdStr); err != nil { + fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", err, cmdStr) + return + } + if len(argv) == 0 { + fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") + return + } + rerun := RerunCommandLine(argv) + fmt.Fprintf(w, "Update complete. Rerunning the command:\n\n %s\n\n", rerun) + if err := reexec(ctx, argv); err != nil { + fmt.Fprintf(w, "Could not rerun automatically (%v). Rerun the command:\n\n %s\n", err, rerun) + } +} + +// realReexecCommand reruns the original invocation with the freshly +// installed binary: argv[0] is re-resolved (through PATH when bare, or +// the same path the installer just replaced) and the child inherits the +// terminal, so interactive flows like the login prompts still work. The +// envUpgradeRerun marker suppresses further update prompts if the rerun +// fails the same way. On success this does not return — the process +// exits with the child's exit code. A spawn-and-exit is used instead of +// syscall.Exec so the one code path covers Windows too. +func realReexecCommand(ctx context.Context, argv []string) error { + if len(argv) == 0 { + return errors.New("original command unknown") + } + path, err := exec.LookPath(argv[0]) + if err != nil { + return fmt.Errorf("locate %s: %w", argv[0], err) + } + cmd := exec.CommandContext(ctx, path, argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), envUpgradeRerun+"=1") + if runErr := cmd.Run(); runErr != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("rerun command: %w", runErr) + } + os.Exit(0) + return nil +} + +// RerunCommandLine reconstructs an invocation for display: argv[0] +// reduced to its base name, arguments with shell-significant whitespace +// or quotes quoted so the line can be pasted back verbatim. +func RerunCommandLine(argv []string) string { + if len(argv) == 0 { + return "" + } + parts := make([]string, 0, len(argv)) + parts = append(parts, filepath.Base(argv[0])) + for _, a := range argv[1:] { + if strings.ContainsAny(a, " \t\"'") { + a = strconv.Quote(a) + } + parts = append(parts, a) + } + return strings.Join(parts, " ") +} diff --git a/cmd/entire/cli/versioncheck/updateflow_test.go b/cmd/entire/cli/versioncheck/updateflow_test.go new file mode 100644 index 0000000000..a04e9f8488 --- /dev/null +++ b/cmd/entire/cli/versioncheck/updateflow_test.go @@ -0,0 +1,175 @@ +package versioncheck + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +// updateFlowFixture wires the seams RunUpdate depends on: the installer +// and the re-exec of the original command. +type updateFlowFixture struct { + installCalls int + installErr error + lastCommand string + reexecCalls [][]string + reexecErr error +} + +func newUpdateFlowFixture(t *testing.T) *updateFlowFixture { + t.Helper() + f := &updateFlowFixture{} + + origRun := runInstaller + runInstaller = func(_ context.Context, cmd string) error { + f.installCalls++ + f.lastCommand = cmd + return f.installErr + } + origReexec := reexec + reexec = func(_ context.Context, argv []string) error { + f.reexecCalls = append(f.reexecCalls, argv) + return f.reexecErr + } + t.Cleanup(func() { + runInstaller = origRun + reexec = origReexec + }) + return f +} + +func TestRunUpdate_NilArgvRunsInstallerWithoutRerun(t *testing.T) { + f := newUpdateFlowFixture(t) + + var out bytes.Buffer + RunUpdate(context.Background(), &out, "brew upgrade --yes entire", nil) + + if f.installCalls != 1 || f.lastCommand != "brew upgrade --yes entire" { + t.Fatalf("installer calls = %d with %q, want 1 with the given command", f.installCalls, f.lastCommand) + } + if len(f.reexecCalls) != 0 { + t.Errorf("reexec must not run without argv, got %v", f.reexecCalls) + } + got := out.String() + if !strings.Contains(got, "Update complete. Re-run entire to use the new version.") { + t.Errorf("missing no-rerun completion message:\n%s", got) + } +} + +func TestRunUpdate_ArgvReexecsOriginalCommand(t *testing.T) { + f := newUpdateFlowFixture(t) + + argv := []string{"/usr/local/bin/entire", "login", "--device"} + var out bytes.Buffer + RunUpdate(context.Background(), &out, "mise upgrade entire", argv) + + if f.installCalls != 1 { + t.Fatalf("installer calls = %d, want 1", f.installCalls) + } + if len(f.reexecCalls) != 1 || strings.Join(f.reexecCalls[0], " ") != strings.Join(argv, " ") { + t.Fatalf("reexec argv = %v, want the original invocation %v", f.reexecCalls, argv) + } + got := out.String() + if !strings.Contains(got, "Update complete. Rerunning the command:") { + t.Errorf("missing rerun announcement:\n%s", got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing rerun command line:\n%s", got) + } +} + +func TestRunUpdate_InstallerFailurePrintsRetryAndSkipsRerun(t *testing.T) { + f := newUpdateFlowFixture(t) + f.installErr = errors.New("brew exploded") + + var out bytes.Buffer + RunUpdate(context.Background(), &out, "brew upgrade --yes entire", []string{"entire", "login"}) + + if len(f.reexecCalls) != 0 { + t.Errorf("reexec must not run after a failed install, got %v", f.reexecCalls) + } + got := out.String() + if !strings.Contains(got, "Update failed") { + t.Errorf("missing failure message:\n%s", got) + } + if !strings.Contains(got, "brew upgrade --yes entire") { + t.Errorf("missing retry command:\n%s", got) + } +} + +func TestRunUpdate_ReexecFailurePrintsManualRerun(t *testing.T) { + f := newUpdateFlowFixture(t) + f.reexecErr = errors.New("exec format error") + + var out bytes.Buffer + RunUpdate(context.Background(), &out, "mise upgrade entire", []string{"entire", "login", "--device"}) + + got := out.String() + if !strings.Contains(got, "Could not rerun automatically") { + t.Errorf("missing manual-rerun fallback:\n%s", got) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing manual rerun command:\n%s", got) + } +} + +func TestPromptAllowed_Gates(t *testing.T) { + useBrewExecutable(t) + pinNonWindowsGOOS(t) + origIsTerminalOut := isTerminalOut + isTerminalOut = func(_ io.Writer) bool { return true } + t.Cleanup(func() { isTerminalOut = origIsTerminalOut }) + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv(envKillSwitch, "") + t.Setenv(envUpgradeRerun, "") + + var out bytes.Buffer + if !PromptAllowed(&out) { + t.Fatal("want allowed with TTY, terminal writer, installer, no env gates") + } + + t.Setenv(envKillSwitch, "1") + if PromptAllowed(&out) { + t.Error("want blocked with kill switch set") + } + t.Setenv(envKillSwitch, "") + + t.Setenv(envUpgradeRerun, "1") + if PromptAllowed(&out) { + t.Error("want blocked on a post-update rerun (loop guard)") + } + t.Setenv(envUpgradeRerun, "") + + isTerminalOut = func(_ io.Writer) bool { return false } + if PromptAllowed(&out) { + t.Error("want blocked for a non-terminal writer") + } +} + +func TestIsPostUpdateRerun(t *testing.T) { + t.Setenv(envUpgradeRerun, "") + if IsPostUpdateRerun() { + t.Error("want false with the marker unset") + } + t.Setenv(envUpgradeRerun, "1") + if !IsPostUpdateRerun() { + t.Error("want true with the marker set") + } +} + +func TestRerunCommandLine(t *testing.T) { + t.Parallel() + + if got := RerunCommandLine([]string{"/usr/local/bin/entire", "api", "/me", "--to", "cell"}); got != "entire api /me --to cell" { + t.Errorf("got %q, want %q", got, "entire api /me --to cell") + } + if got := RerunCommandLine([]string{"entire", "dispatch", "fix the thing"}); got != `entire dispatch "fix the thing"` { + t.Errorf("got %q, want quoted arg", got) + } + if got := RerunCommandLine(nil); got != "" { + t.Errorf("got %q, want empty for nil argv", got) + } +} diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index 40c1ca5fe2..0eed10c6d6 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -417,13 +417,6 @@ func UpdateCommandForCurrentBinary(currentVersion string) string { return updateCommand(currentVersion) } -// CanAutoInstall reports whether UpdateCommandForCurrentBinary returns a -// runnable installer command rather than a downloads-page URL, i.e. -// whether it is safe to offer executing it. -func CanAutoInstall() bool { - return canAutoInstall() -} - // printNotification prints the version update notification to the user. func printNotification(w io.Writer, current, latest string) { fmt.Fprintf(w, "\nUpdate available! %s -> %s\nRelease notes: %s\n", From f61366b68ec75f958638498b65bbbe80d0c0995d Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Mon, 20 Jul 2026 11:02:42 +0200 Subject: [PATCH 8/8] polish: propagate rerun exit code instead of exiting inside the flow Address review feedback on the shared update flow: - realReexecCommand no longer calls os.Exit: it returns the child's exit code, mapping a signal-killed child to the shell convention 128+signum (SIGINT -> 130) instead of os.Exit(-1) -> 255. On ctx cancel it forwards an interrupt instead of the default SIGKILL so the child can unwind; WaitDelay hard-kills after 5s. - RunUpdate returns (exitCode, rerun) and OfferCLIUpgradeIfRequired returns (handled, exitCode), so the process exit stays with main.go's single exit point and callers' cleanup always runs. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 01KXZC6SR6JTCBX7VW6G482646 --- cmd/entire/cli/upgrade_required.go | 35 ++++++---- cmd/entire/cli/upgrade_required_test.go | 25 ++++--- cmd/entire/cli/versioncheck/updateflow.go | 67 +++++++++++++------ .../cli/versioncheck/updateflow_test.go | 52 ++++++++++++-- cmd/entire/main.go | 13 +++- 5 files changed, 139 insertions(+), 53 deletions(-) diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go index 2500ab531d..75f66d15f1 100644 --- a/cmd/entire/cli/upgrade_required.go +++ b/cmd/entire/cli/upgrade_required.go @@ -37,26 +37,31 @@ func IsCLIUpgradeRequired(err error) bool { type upgradeOfferDeps struct { canPrompt func() bool confirm func(ctx context.Context, updateCmd string) (bool, error) - runUpdate func(ctx context.Context, w io.Writer, cmdStr string, argv []string) + runUpdate func(ctx context.Context, w io.Writer, cmdStr string, argv []string) (exitCode int, rerun bool) } // OfferCLIUpgradeIfRequired handles a cli_upgrade_required failure in -// place of the raw error print: it reports true when it took over the -// messaging (main.go then skips printing err — the raw OAuth chain adds -// nothing the guidance doesn't say), false when err doesn't carry the -// code. On an interactive terminal it offers to run the updater right -// away and, on success, re-executes the original command with the +// place of the raw error print: handled reports true when it took over +// the messaging (main.go then skips printing err — the raw OAuth chain +// adds nothing the guidance doesn't say), false when err doesn't carry +// the code. On an interactive terminal it offers to run the updater +// right away and, on success, re-executes the original command with the // freshly installed binary (the shared versioncheck.RunUpdate tail, also // used by the version-check prompt); otherwise — no TTY, // ENTIRE_NO_AUTO_UPDATE set, declined, or no runnable installer for this // platform — it prints the update command and the failed command so the // user can run both. argv is the failed invocation's os.Args. // +// When handled, exitCode is what the process should exit with: the rerun +// child's exit code when the update ran and the command was re-executed +// (0 when the retry succeeded), 1 on every other leaf. The exit itself +// stays with main so its cleanup still runs. +// // The code can surface from any auth-server OAuth flow — login (device // and browser), the refresh grant under every authenticated command, and // the RFC 8693 exchanges — which is why this hangs off main.go's central // error-print arm rather than any single command. -func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) bool { +func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv []string) (handled bool, exitCode int) { return offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ canPrompt: func() bool { return versioncheck.PromptAllowed(w) }, confirm: confirmCLIUpgrade, @@ -64,9 +69,9 @@ func OfferCLIUpgradeIfRequired(ctx context.Context, w io.Writer, err error, argv }) } -func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) bool { +func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) (handled bool, exitCode int) { if !IsCLIUpgradeRequired(err) { - return false + return false, 1 } updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) rerun := versioncheck.RerunCommandLine(argv) @@ -83,12 +88,12 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, binary = argv[0] } fmt.Fprintf(w, "The update installed, but this command still ran an unsupported binary (%s).\nCheck that `entire` on your PATH points at the updated install, then rerun:\n\n %s\n", binary, rerun) - return true + return true, 1 } if !deps.canPrompt() { printCLIUpgradeCommands(w, updateCmd, rerun) - return true + return true, 1 } confirmed, confirmErr := deps.confirm(ctx, updateCmd) @@ -96,11 +101,13 @@ func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, // Declined or the prompt itself failed/was aborted — leave the // copyable commands either way. printCLIUpgradeCommands(w, updateCmd, rerun) - return true + return true, 1 } - deps.runUpdate(ctx, w, updateCmd, argv) - return true + if code, rerunRan := deps.runUpdate(ctx, w, updateCmd, argv); rerunRan { + return true, code + } + return true, 1 } // printCLIUpgradeCommands prints the non-interactive guidance block: the diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go index e77c59ae6a..8f09f48caa 100644 --- a/cmd/entire/cli/upgrade_required_test.go +++ b/cmd/entire/cli/upgrade_required_test.go @@ -38,7 +38,7 @@ func TestOfferCLIUpgrade_UnrelatedErrorPrintsNothing(t *testing.T) { t.Parallel() var out bytes.Buffer - handled := offerCLIUpgrade(context.Background(), &out, errors.New("boom"), loginArgv(), upgradeOfferDeps{ + handled, _ := offerCLIUpgrade(context.Background(), &out, errors.New("boom"), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { t.Error("canPrompt must not be consulted"); return false }, }) if handled { @@ -53,13 +53,16 @@ func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T t.Parallel() var out bytes.Buffer - handled := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + handled, exitCode := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return false }, }) if !handled { t.Error("handled = false, want true so main.go skips the raw error") } + if exitCode != 1 { + t.Errorf("exitCode = %d, want 1 on the guidance-only leaf", exitCode) + } got := out.String() if !strings.Contains(got, "This Entire CLI version is no longer supported. Update it:") { t.Errorf("missing unsupported-version message:\n%s", got) @@ -84,11 +87,12 @@ func TestOfferCLIUpgrade_ConfirmYesRunsSharedUpdateFlow(t *testing.T) { } var calls []updateCall var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + _, exitCode := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return true }, confirm: func(context.Context, string) (bool, error) { return true, nil }, - runUpdate: func(_ context.Context, _ io.Writer, cmdStr string, argv []string) { + runUpdate: func(_ context.Context, _ io.Writer, cmdStr string, argv []string) (int, bool) { calls = append(calls, updateCall{cmdStr: cmdStr, argv: argv}) + return 3, true }, }) @@ -102,6 +106,9 @@ func TestOfferCLIUpgrade_ConfirmYesRunsSharedUpdateFlow(t *testing.T) { if strings.Join(calls[0].argv, " ") != strings.Join(loginArgv(), " ") { t.Errorf("runUpdate argv = %v, want the original invocation %v", calls[0].argv, loginArgv()) } + if exitCode != 3 { + t.Errorf("exitCode = %d, want the rerun child's exit code 3", exitCode) + } } func TestOfferCLIUpgrade_RerunGuardExplainsStaleBinary(t *testing.T) { @@ -109,10 +116,11 @@ func TestOfferCLIUpgrade_RerunGuardExplainsStaleBinary(t *testing.T) { t.Setenv("ENTIRE_UPGRADE_RERUN", "1") var out bytes.Buffer - handled := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + handled, _ := offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { t.Error("prompt must be suppressed on a post-update rerun"); return true }, - runUpdate: func(context.Context, io.Writer, string, []string) { + runUpdate: func(context.Context, io.Writer, string, []string) (int, bool) { t.Error("update must not run again on a post-update rerun") + return 0, false }, }) @@ -135,11 +143,12 @@ func TestOfferCLIUpgrade_ConfirmNoPrintsCommands(t *testing.T) { t.Parallel() var out bytes.Buffer - offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ + _, _ = offerCLIUpgrade(context.Background(), &out, upgradeErr(), loginArgv(), upgradeOfferDeps{ canPrompt: func() bool { return true }, confirm: func(context.Context, string) (bool, error) { return false, nil }, - runUpdate: func(context.Context, io.Writer, string, []string) { + runUpdate: func(context.Context, io.Writer, string, []string) (int, bool) { t.Error("update must not run when declined") + return 0, false }, }) diff --git a/cmd/entire/cli/versioncheck/updateflow.go b/cmd/entire/cli/versioncheck/updateflow.go index 242e875c7c..50f415d6b4 100644 --- a/cmd/entire/cli/versioncheck/updateflow.go +++ b/cmd/entire/cli/versioncheck/updateflow.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strconv" "strings" + "syscall" + "time" "github.com/entireio/cli/cmd/entire/cli/interactive" ) @@ -56,23 +58,30 @@ func PromptAllowed(w io.Writer) bool { // command already completed — re-executing would run it twice); a non-nil // argv is re-executed with the freshly installed binary (the // cli_upgrade_required trigger fires after the command failed, so the -// rerun is a retry). On a successful re-exec the process exits with the -// child's exit code and this function does not return. -func RunUpdate(ctx context.Context, w io.Writer, cmdStr string, argv []string) { +// rerun is a retry). rerun=true means the original command was re-executed +// and exitCode is the child's exit code (128+signum when a signal killed +// it) for the caller to propagate; rerun=false means nothing was rerun — +// nil argv, installer failure, or spawn failure — and the caller keeps its +// own exit path. RunUpdate never terminates the process itself, so +// callers' cleanup still runs. +func RunUpdate(ctx context.Context, w io.Writer, cmdStr string, argv []string) (exitCode int, rerun bool) { fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr) if err := runInstaller(ctx, cmdStr); err != nil { fmt.Fprintf(w, "Update failed: %v\nTry again later running:\n %s\n", err, cmdStr) - return + return 0, false } if len(argv) == 0 { fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") - return + return 0, false } - rerun := RerunCommandLine(argv) - fmt.Fprintf(w, "Update complete. Rerunning the command:\n\n %s\n\n", rerun) - if err := reexec(ctx, argv); err != nil { - fmt.Fprintf(w, "Could not rerun automatically (%v). Rerun the command:\n\n %s\n", err, rerun) + rerunLine := RerunCommandLine(argv) + fmt.Fprintf(w, "Update complete. Rerunning the command:\n\n %s\n\n", rerunLine) + code, err := reexec(ctx, argv) + if err != nil { + fmt.Fprintf(w, "Could not rerun automatically (%v). Rerun the command:\n\n %s\n", err, rerunLine) + return 0, false } + return code, true } // realReexecCommand reruns the original invocation with the freshly @@ -80,30 +89,44 @@ func RunUpdate(ctx context.Context, w io.Writer, cmdStr string, argv []string) { // the same path the installer just replaced) and the child inherits the // terminal, so interactive flows like the login prompts still work. The // envUpgradeRerun marker suppresses further update prompts if the rerun -// fails the same way. On success this does not return — the process -// exits with the child's exit code. A spawn-and-exit is used instead of -// syscall.Exec so the one code path covers Windows too. -func realReexecCommand(ctx context.Context, argv []string) error { +// fails the same way. It returns the child's exit code — 128+signum when +// a signal killed the child, matching the shell convention — or an error +// when the child could not be spawned at all. A spawn-and-wait is used +// instead of syscall.Exec so the one code path covers Windows too. +func realReexecCommand(ctx context.Context, argv []string) (int, error) { if len(argv) == 0 { - return errors.New("original command unknown") + return 0, errors.New("original command unknown") } path, err := exec.LookPath(argv[0]) if err != nil { - return fmt.Errorf("locate %s: %w", argv[0], err) + return 0, fmt.Errorf("locate %s: %w", argv[0], err) } cmd := exec.CommandContext(ctx, path, argv[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = append(os.Environ(), envUpgradeRerun+"=1") - if runErr := cmd.Run(); runErr != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { - os.Exit(exitErr.ExitCode()) - } - return fmt.Errorf("rerun command: %w", runErr) + // On ctx cancel (the parent's signal handler fired) forward an + // interrupt instead of the default SIGKILL so the child can unwind + // cleanly; WaitDelay hard-kills a child that ignores it. On Windows + // Signal(os.Interrupt) is unsupported and the WaitDelay kill applies. + cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } + cmd.WaitDelay = 5 * time.Second + runErr := cmd.Run() + if runErr == nil { + return 0, nil + } + exitErr, ok := errors.AsType[*exec.ExitError](runErr) + if !ok { + return 0, fmt.Errorf("rerun command: %w", runErr) + } + if code := exitErr.ExitCode(); code >= 0 { + return code, nil + } + if ws, wsOK := exitErr.Sys().(syscall.WaitStatus); wsOK && ws.Signaled() { + return 128 + int(ws.Signal()), nil } - os.Exit(0) - return nil + return 1, nil } // RerunCommandLine reconstructs an invocation for display: argv[0] diff --git a/cmd/entire/cli/versioncheck/updateflow_test.go b/cmd/entire/cli/versioncheck/updateflow_test.go index a04e9f8488..5f5afb7986 100644 --- a/cmd/entire/cli/versioncheck/updateflow_test.go +++ b/cmd/entire/cli/versioncheck/updateflow_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "runtime" "strings" "testing" ) @@ -16,6 +17,7 @@ type updateFlowFixture struct { installErr error lastCommand string reexecCalls [][]string + reexecCode int reexecErr error } @@ -30,9 +32,9 @@ func newUpdateFlowFixture(t *testing.T) *updateFlowFixture { return f.installErr } origReexec := reexec - reexec = func(_ context.Context, argv []string) error { + reexec = func(_ context.Context, argv []string) (int, error) { f.reexecCalls = append(f.reexecCalls, argv) - return f.reexecErr + return f.reexecCode, f.reexecErr } t.Cleanup(func() { runInstaller = origRun @@ -45,8 +47,11 @@ func TestRunUpdate_NilArgvRunsInstallerWithoutRerun(t *testing.T) { f := newUpdateFlowFixture(t) var out bytes.Buffer - RunUpdate(context.Background(), &out, "brew upgrade --yes entire", nil) + _, rerun := RunUpdate(context.Background(), &out, "brew upgrade --yes entire", nil) + if rerun { + t.Error("rerun = true, want false for nil argv") + } if f.installCalls != 1 || f.lastCommand != "brew upgrade --yes entire" { t.Fatalf("installer calls = %d with %q, want 1 with the given command", f.installCalls, f.lastCommand) } @@ -61,10 +66,11 @@ func TestRunUpdate_NilArgvRunsInstallerWithoutRerun(t *testing.T) { func TestRunUpdate_ArgvReexecsOriginalCommand(t *testing.T) { f := newUpdateFlowFixture(t) + f.reexecCode = 3 argv := []string{"/usr/local/bin/entire", "login", "--device"} var out bytes.Buffer - RunUpdate(context.Background(), &out, "mise upgrade entire", argv) + code, rerun := RunUpdate(context.Background(), &out, "mise upgrade entire", argv) if f.installCalls != 1 { t.Fatalf("installer calls = %d, want 1", f.installCalls) @@ -72,6 +78,9 @@ func TestRunUpdate_ArgvReexecsOriginalCommand(t *testing.T) { if len(f.reexecCalls) != 1 || strings.Join(f.reexecCalls[0], " ") != strings.Join(argv, " ") { t.Fatalf("reexec argv = %v, want the original invocation %v", f.reexecCalls, argv) } + if !rerun || code != 3 { + t.Errorf("(code, rerun) = (%d, %v), want the child's exit code (3, true)", code, rerun) + } got := out.String() if !strings.Contains(got, "Update complete. Rerunning the command:") { t.Errorf("missing rerun announcement:\n%s", got) @@ -86,8 +95,11 @@ func TestRunUpdate_InstallerFailurePrintsRetryAndSkipsRerun(t *testing.T) { f.installErr = errors.New("brew exploded") var out bytes.Buffer - RunUpdate(context.Background(), &out, "brew upgrade --yes entire", []string{"entire", "login"}) + _, rerun := RunUpdate(context.Background(), &out, "brew upgrade --yes entire", []string{"entire", "login"}) + if rerun { + t.Error("rerun = true, want false after a failed install") + } if len(f.reexecCalls) != 0 { t.Errorf("reexec must not run after a failed install, got %v", f.reexecCalls) } @@ -105,8 +117,11 @@ func TestRunUpdate_ReexecFailurePrintsManualRerun(t *testing.T) { f.reexecErr = errors.New("exec format error") var out bytes.Buffer - RunUpdate(context.Background(), &out, "mise upgrade entire", []string{"entire", "login", "--device"}) + _, rerun := RunUpdate(context.Background(), &out, "mise upgrade entire", []string{"entire", "login", "--device"}) + if rerun { + t.Error("rerun = true, want false when the re-exec could not spawn") + } got := out.String() if !strings.Contains(got, "Could not rerun automatically") { t.Errorf("missing manual-rerun fallback:\n%s", got) @@ -116,6 +131,31 @@ func TestRunUpdate_ReexecFailurePrintsManualRerun(t *testing.T) { } } +func TestRealReexecCommand_ExitCodes(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("uses sh") + } + t.Parallel() + + if code, err := realReexecCommand(context.Background(), []string{"sh", "-c", "true"}); err != nil || code != 0 { + t.Errorf("(code, err) = (%d, %v), want (0, nil) for a successful child", code, err) + } + if code, err := realReexecCommand(context.Background(), []string{"sh", "-c", "exit 3"}); err != nil || code != 3 { + t.Errorf("(code, err) = (%d, %v), want the child's exit code (3, nil)", code, err) + } + // A signal-killed child must map to the shell convention 128+signum + // (SIGINT → 130), not a raw -1 from ExitCode. + if code, err := realReexecCommand(context.Background(), []string{"sh", "-c", "kill -INT $$"}); err != nil || code != 130 { + t.Errorf("(code, err) = (%d, %v), want (130, nil) for a SIGINT-killed child", code, err) + } + if _, err := realReexecCommand(context.Background(), []string{"entire-definitely-not-installed-binary"}); err == nil { + t.Error("want a spawn error for an unresolvable binary") + } + if _, err := realReexecCommand(context.Background(), nil); err == nil { + t.Error("want an error for empty argv") + } +} + func TestPromptAllowed_Gates(t *testing.T) { useBrewExecutable(t) pinNonWindowsGOOS(t) diff --git a/cmd/entire/main.go b/cmd/entire/main.go index a99a67140e..81bbbe7475 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -87,6 +87,7 @@ func main() { executed, err := rootCmd.ExecuteContextC(ctx) if err != nil { var silent *cli.SilentError + exitCode := 1 switch { case errors.Is(err, context.Canceled) && procsignal.Load() != nil: @@ -128,14 +129,20 @@ func main() { // The handler replaces the raw cli_upgrade_required error with // actionable guidance — an interactive update offer, or the // update + rerun commands. Handled here so every command gets - // it without wrapping its own errors. - if !cli.OfferCLIUpgradeIfRequired(ctx, rootCmd.OutOrStderr(), err, os.Args) { + // it without wrapping its own errors. When the update ran and + // the original command was re-executed, code is the rerun + // child's exit code (0 when the retry succeeded); on every + // other leaf it stays 1. + handled, code := cli.OfferCLIUpgradeIfRequired(ctx, rootCmd.OutOrStderr(), err, os.Args) + if handled { + exitCode = code + } else { fmt.Fprintln(rootCmd.OutOrStderr(), err) } } cancel() - os.Exit(1) + os.Exit(exitCode) } if cli.ShouldCheckCheckpointPolicyWarning(executed) { cli.WarnCheckpointPolicyIfNeeded(ctx, rootCmd.ErrOrStderr(), versioninfo.Version)