diff --git a/cmd/entire/cli/upgrade_required.go b/cmd/entire/cli/upgrade_required.go new file mode 100644 index 0000000000..75f66d15f1 --- /dev/null +++ b/cmd/entire/cli/upgrade_required.go @@ -0,0 +1,137 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "charm.land/huh/v2" + + "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, +// 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 +// 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, a +// real installer, or replacing the test process. Zero-value fields are +// 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) + 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: 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) (handled bool, exitCode int) { + return offerCLIUpgrade(ctx, w, err, argv, upgradeOfferDeps{ + canPrompt: func() bool { return versioncheck.PromptAllowed(w) }, + confirm: confirmCLIUpgrade, + runUpdate: versioncheck.RunUpdate, + }) +} + +func offerCLIUpgrade(ctx context.Context, w io.Writer, err error, argv []string, deps upgradeOfferDeps) (handled bool, exitCode int) { + if !IsCLIUpgradeRequired(err) { + return false, 1 + } + updateCmd := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + 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. + // (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] + } + 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, 1 + } + + if !deps.canPrompt() { + printCLIUpgradeCommands(w, updateCmd, rerun) + return true, 1 + } + + 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 true, 1 + } + + if code, rerunRan := deps.runUpdate(ctx, w, updateCmd, argv); rerunRan { + return true, code + } + return true, 1 +} + +// 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, "%s\n\n %s\n", cliUnsupportedMsg, updateCmd) + if rerun != "" { + fmt.Fprintf(w, "\nThen rerun the command:\n\n %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 +} diff --git a/cmd/entire/cli/upgrade_required_test.go b/cmd/entire/cli/upgrade_required_test.go new file mode 100644 index 0000000000..8f09f48caa --- /dev/null +++ b/cmd/entire/cli/upgrade_required_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "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 + 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()) + } +} + +func TestOfferCLIUpgrade_NonInteractivePrintsUpdateAndRerunCommands(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + 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) + } + 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) + } +} + +func TestOfferCLIUpgrade_ConfirmYesRunsSharedUpdateFlow(t *testing.T) { + t.Parallel() + + type updateCall struct { + cmdStr string + argv []string + } + var calls []updateCall + var out bytes.Buffer + _, 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) (int, bool) { + calls = append(calls, updateCall{cmdStr: cmdStr, argv: argv}) + return 3, true + }, + }) + + want := versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version) + if len(calls) != 1 { + t.Fatalf("runUpdate calls = %d, want exactly 1", len(calls)) + } + if calls[0].cmdStr != want { + t.Errorf("runUpdate command = %q, want %q", calls[0].cmdStr, want) + } + 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) { + // t.Setenv forbids t.Parallel. + 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 }, + 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 + }, + }) + + 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) + } +} + +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 }, + runUpdate: func(context.Context, io.Writer, string, []string) (int, bool) { + t.Error("update must not run when declined") + return 0, false + }, + }) + + 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) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing failed command to rerun:\n%s", got) + } +} diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go index d088689c3d..1c03723133 100644 --- a/cmd/entire/cli/versioncheck/autoupdate.go +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -70,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 @@ -84,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..50f415d6b4 --- /dev/null +++ b/cmd/entire/cli/versioncheck/updateflow.go @@ -0,0 +1,148 @@ +package versioncheck + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "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). 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 0, false + } + if len(argv) == 0 { + fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") + return 0, false + } + 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 +// 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. 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 0, errors.New("original command unknown") + } + path, err := exec.LookPath(argv[0]) + if err != nil { + 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") + // 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 + } + return 1, 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..5f5afb7986 --- /dev/null +++ b/cmd/entire/cli/versioncheck/updateflow_test.go @@ -0,0 +1,215 @@ +package versioncheck + +import ( + "bytes" + "context" + "errors" + "io" + "runtime" + "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 + reexecCode int + 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) (int, error) { + f.reexecCalls = append(f.reexecCalls, argv) + return f.reexecCode, f.reexecErr + } + t.Cleanup(func() { + runInstaller = origRun + reexec = origReexec + }) + return f +} + +func TestRunUpdate_NilArgvRunsInstallerWithoutRerun(t *testing.T) { + f := newUpdateFlowFixture(t) + + var out bytes.Buffer + _, 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) + } + 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) + f.reexecCode = 3 + + argv := []string{"/usr/local/bin/entire", "login", "--device"} + var out bytes.Buffer + code, rerun := 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) + } + 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) + } + 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 + _, 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) + } + 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 + _, 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) + } + if !strings.Contains(got, "entire login --device") { + t.Errorf("missing manual rerun command:\n%s", got) + } +} + +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) + 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/main.go b/cmd/entire/main.go index a658e7c923..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: @@ -123,11 +124,25 @@ 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. + // 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. 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)