Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 40 additions & 104 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
package versioncheck

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"

"github.com/charmbracelet/huh"

Expand All @@ -29,17 +27,26 @@ const (
autoUpdateActionSkipUntilNextVersion AutoUpdateAction = "skip_until_next_version"
)

// chooseUpdateFn is the signature for the update-prompt seam. The
// concrete implementation renders a huh.Select with the installer
// command interpolated into option 1.
type chooseUpdateFn func(ctx context.Context, currentVersion, latestVersion, cmdStr string) (AutoUpdateAction, error)

// Test seams.
var (
runInstaller = realRunInstaller
confirmUpdate = realConfirmUpdate
chooseBrewUpdate = realChooseBrewUpdate
isTerminalOut = interactive.IsTerminalWriter
runInstaller = realRunInstaller
chooseUpdate chooseUpdateFn = realChooseUpdate
isTerminalOut = interactive.IsTerminalWriter
)

// MaybeAutoUpdate prints an update notification and offers an interactive
// upgrade. Silent on every failure path — it must never interrupt the CLI.
//
// The same 3-option prompt (update / skip / skip until next version) is
// shown for every install manager that supports auto-installation
// (brew, mise, scoop, curl-bash). The only thing that varies between
// installers is the shell command interpolated into option 1.
//
// If the installer command fails, a hint with the exact command is
// printed so the user can retry manually. The 24h version-check cache
// is not invalidated on failure: we don't want to re-prompt on every
Expand All @@ -49,45 +56,17 @@ var (
// When the prompt cannot be shown (kill switch set, or non-interactive
// environment like CI / agent subprocess / no TTY) the installer
// command is printed so the user still learns what to run manually.
//
// On Windows + unknown install manager the POSIX curl-pipe-bash fallback
// can't auto-run and there's no native equivalent, so we point the user
// at the releases download page instead.
func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVersion string) AutoUpdateAction {
if installManagerForCurrentBinary() == installManagerBrew {
return maybeBrewAutoUpdate(ctx, w, currentVersion, latestVersion)
}

printNotification(w, currentVersion, latestVersion)

// Windows + unknown install manager: the POSIX curl-pipe-bash fallback
// would error if auto-run, and there's no safe native equivalent. Point
// the user at the releases page so they can download manually.
if !canAutoInstall() {
printNotification(w, currentVersion, latestVersion)
fmt.Fprintf(w, "To update, download the latest release from:\n %s\n", downloadsURL)
return autoUpdateActionSkip
}
if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) {
fmt.Fprintf(w, "To update, run:\n %s\n", updateCommand(currentVersion))
return autoUpdateActionSkip
}

confirmed, err := confirmUpdate()
if err != nil {
logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error())
return autoUpdateActionSkip
}
if !confirmed {
return autoUpdateActionSkip
}

cmdStr := updateCommand(currentVersion)
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.")
return autoUpdateActionUpdate
}

func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, latestVersion string) AutoUpdateAction {
cmdStr := updateCommand(currentVersion)

if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) {
Expand All @@ -96,11 +75,9 @@ func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, lates
return autoUpdateActionSkip
}

printBrewUpdateMessage(w, currentVersion, latestVersion, cmdStr)

action, err := chooseBrewUpdate(w)
action, err := chooseUpdate(ctx, currentVersion, latestVersion, cmdStr)
if err != nil {
logging.Debug(ctx, "auto-update: brew prompt failed", "error", err.Error())
logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error())
return autoUpdateActionSkip
}

Expand All @@ -122,73 +99,32 @@ func maybeBrewAutoUpdate(ctx context.Context, w io.Writer, currentVersion, lates
}
}

func printBrewUpdateMessage(w io.Writer, currentVersion, latestVersion, cmdStr string) {
fmt.Fprintf(w, "\nUpdate available! %s -> %s\nRelease notes: %s\n1. Update now (runs `%s`)\n2. Skip\n3. Skip until next version\n\nPress enter to continue\n",
displayVersion(currentVersion), displayVersion(latestVersion), releaseNotesURL(latestVersion), cmdStr)
}

func realChooseBrewUpdate(w io.Writer) (AutoUpdateAction, error) {
return chooseBrewUpdateFromReader(w, os.Stdin)
}

func chooseBrewUpdateFromReader(w io.Writer, input io.Reader) (AutoUpdateAction, error) {
reader := bufio.NewReader(input)
for {
fmt.Fprint(w, "Choose an option [1]: ")
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return autoUpdateActionSkip, fmt.Errorf("read update choice: %w", err)
}
if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" {
return autoUpdateActionSkip, nil
}

action, ok := parseBrewUpdateChoice(line)
if ok {
return action, nil
}
if errors.Is(err, io.EOF) {
return autoUpdateActionSkip, nil
}
fmt.Fprintln(w, "Please choose 1, 2, or 3.")
}
}

func parseBrewUpdateChoice(input string) (AutoUpdateAction, bool) {
switch strings.TrimSpace(input) {
case "", "1":
return autoUpdateActionUpdate, true
case "2":
return autoUpdateActionSkip, true
case "3":
return autoUpdateActionSkipUntilNextVersion, true
default:
return autoUpdateActionSkip, false
}
}

func realConfirmUpdate() (bool, error) {
// Pre-select "Yes" so pressing Enter accepts — matches the (Y/n) UX.
confirmed := true
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Install the new version now?").
Affirmative("Yes").
Negative("No").
Value(&confirmed),
),
).WithTheme(huh.ThemeDracula())
// realChooseUpdate renders a huh.Select with the three update actions.
// In normal mode this is an arrow-key TUI; when ACCESSIBLE is set huh
// falls back to a plain numbered prompt readable by screen readers.
func realChooseUpdate(ctx context.Context, currentVersion, latestVersion, cmdStr string) (AutoUpdateAction, error) {
action := autoUpdateActionUpdate
sel := huh.NewSelect[AutoUpdateAction]().
Title(fmt.Sprintf("Update available! %s -> %s",
displayVersion(currentVersion), displayVersion(latestVersion))).
Description("Release notes: "+releaseNotesURL(latestVersion)).
Options(
huh.NewOption(fmt.Sprintf("Update now (runs `%s`)", cmdStr), autoUpdateActionUpdate),
huh.NewOption("Skip", autoUpdateActionSkip),
huh.NewOption("Skip until next version", autoUpdateActionSkipUntilNextVersion),
).
Value(&action)
form := huh.NewForm(huh.NewGroup(sel)).WithTheme(huh.ThemeDracula())
if os.Getenv("ACCESSIBLE") != "" {
form = form.WithAccessible(true)
}
if err := form.Run(); err != nil {
if err := form.RunWithContext(ctx); err != nil {
if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) {
return false, nil
return autoUpdateActionSkip, nil
}
return false, fmt.Errorf("confirm form: %w", err)
return autoUpdateActionSkip, fmt.Errorf("update prompt: %w", err)
}
return confirmed, nil
return action, nil
}

// realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr
Expand Down
Loading
Loading