Skip to content
137 changes: 137 additions & 0 deletions cmd/entire/cli/upgrade_required.go
Original file line number Diff line number Diff line change
@@ -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
}
162 changes: 162 additions & 0 deletions cmd/entire/cli/upgrade_required_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 4 additions & 7 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading