Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a6f62a8
Add inline "update now?" prompt after version notification
gtrrz-victor Apr 21, 2026
ce3acf9
Default confirm prompt to Yes
gtrrz-victor Apr 22, 2026
5872166
Gate auto-update prompt on /dev/tty, not stdout
gtrrz-victor Apr 22, 2026
b7d13a4
Add CI guardrail to CanPromptInteractively
gtrrz-victor Apr 22, 2026
55c7f89
Drop docs/architecture/auto-update.md from PR
gtrrz-victor Apr 22, 2026
dfa4f39
Merge branch 'main' into auto-update
gtrrz-victor Apr 22, 2026
1d1e012
Set ENTIRE_TEST_TTY=1 in pty-based integration tests
gtrrz-victor Apr 22, 2026
7c3d8a7
Consolidate TTY gating on interactive.HasTTY
gtrrz-victor Apr 22, 2026
f67d983
Rename interactive.HasTTY back to CanPromptInteractively
gtrrz-victor Apr 22, 2026
fc3d0e3
Skip interactive prompts when CI env var is set
gtrrz-victor Apr 23, 2026
f2e0063
Invalidate version-check cache when installer fails
gtrrz-victor Apr 23, 2026
bd23fc5
Merge remote-tracking branch 'origin/main' into auto-update
gtrrz-victor Apr 23, 2026
879a3cf
Simplify auto-update decline/failure UX
gtrrz-victor Apr 23, 2026
f666a09
Force ENTIRE_TEST_TTY=1 in pty-based integration tests
gtrrz-victor Apr 23, 2026
649bd22
Allow overriding version-check URLs via env vars for local testing
gtrrz-victor Apr 23, 2026
7fbc348
Tighten auto-update banner and failure-hint wording
gtrrz-victor Apr 23, 2026
69db6fa
Drop ENTIRE_VERSION_CHECK_URL env overrides
gtrrz-victor Apr 23, 2026
99b5315
Merge branch 'main' into auto-update
gtrrz-victor Apr 23, 2026
eb78adf
Print manual-update hint when prompt can't be shown
gtrrz-victor Apr 23, 2026
176ca2d
Fix build: qualify isTerminalWriter in activity_cmd.go
gtrrz-victor Apr 23, 2026
59918d0
Reword manual-update hint to "To update, run:"
gtrrz-victor Apr 23, 2026
eb9b0df
Treat CI=false as "not CI" for prompt gating
gtrrz-victor Apr 23, 2026
c8d4abf
Skip auto-update prompts on redirected output
gtrrz-victor Apr 23, 2026
d20d773
Skip auto-run on Windows when install manager is unknown
gtrrz-victor Apr 23, 2026
28c05ec
Merge branch 'main' into auto-update
gtrrz-victor Apr 23, 2026
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
96 changes: 96 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package versioncheck

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

"github.com/charmbracelet/huh"

"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
)

// envKillSwitch disables the interactive update prompt regardless of TTY.
const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE"

// Test seams.
var (
runInstaller = realRunInstaller
confirmUpdate = realConfirmUpdate
)

// MaybeAutoUpdate offers an interactive upgrade after the standard
// "version available" notification has been printed. Silent on every
// failure path — it must never interrupt the CLI.
func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion string) {
if os.Getenv(envKillSwitch) != "" {
return
}
if !interactive.CanPromptInteractively() {
return
}
Comment thread
gtrrz-victor marked this conversation as resolved.

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

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\n", err)
return
}
fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.")
}

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),
),
Comment thread
gtrrz-victor marked this conversation as resolved.
).WithTheme(huh.ThemeDracula())
if os.Getenv("ACCESSIBLE") != "" {
form = form.WithAccessible(true)
}
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) {
return false, nil
}
return false, fmt.Errorf("confirm form: %w", err)
}
return confirmed, nil
}

// realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr
// so password prompts and progress output reach the user.
func realRunInstaller(ctx context.Context, cmdStr string) error {
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.CommandContext(ctx, "cmd", "/C", cmdStr)
} else {
c = exec.CommandContext(ctx, "sh", "-c", cmdStr)
}
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("installer exited: %w", err)
}
return nil
}
126 changes: 126 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package versioncheck

import (
"bytes"
"context"
"errors"
"strings"
"testing"
)

// autoUpdateFixture wires the test seams for MaybeAutoUpdate.
type autoUpdateFixture struct {
installCalls int
installErr error
lastCommand string
confirmValue bool
confirmErr error
}

func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv(envKillSwitch, "")
// Force interactive mode on by default; individual tests can opt out.
t.Setenv("ENTIRE_TEST_TTY", "1")

f := &autoUpdateFixture{confirmValue: true}

origRun := runInstaller
runInstaller = func(_ context.Context, cmd string) error {
f.installCalls++
f.lastCommand = cmd
return f.installErr
}
origConfirm := confirmUpdate
confirmUpdate = func() (bool, error) { return f.confirmValue, f.confirmErr }

t.Cleanup(func() {
runInstaller = origRun
confirmUpdate = origConfirm
})
return f
}

// useBrewExecutable points the install-manager detector at a brew cellar path.
func useBrewExecutable(t *testing.T) {
t.Helper()
orig := executablePath
executablePath = func() (string, error) {
return "/opt/homebrew/Cellar/entire/1.0.0/bin/entire", nil
}
t.Cleanup(func() { executablePath = orig })
}

func TestMaybeAutoUpdate_KillSwitch(t *testing.T) {
f := newAutoUpdateFixture(t)
useBrewExecutable(t)
t.Setenv(envKillSwitch, "1")

var buf bytes.Buffer
MaybeAutoUpdate(context.Background(), &buf, "1.0.0")

if f.installCalls != 0 {
t.Errorf("installer called with kill-switch set")
}
}

func TestMaybeAutoUpdate_NoTTY(t *testing.T) {
f := newAutoUpdateFixture(t)
useBrewExecutable(t)
t.Setenv("ENTIRE_TEST_TTY", "0")

var buf bytes.Buffer
MaybeAutoUpdate(context.Background(), &buf, "1.0.0")

if f.installCalls != 0 {
t.Errorf("installer called without TTY")
}
}

func TestMaybeAutoUpdate_UserDeclines(t *testing.T) {
f := newAutoUpdateFixture(t)
useBrewExecutable(t)
f.confirmValue = false

var buf bytes.Buffer
MaybeAutoUpdate(context.Background(), &buf, "1.0.0")

if f.installCalls != 0 {
t.Errorf("installer called after user declined")
}
}

func TestMaybeAutoUpdate_HappyPath(t *testing.T) {
f := newAutoUpdateFixture(t)
useBrewExecutable(t)

var buf bytes.Buffer
MaybeAutoUpdate(context.Background(), &buf, "1.0.0")

if f.installCalls != 1 {
t.Fatalf("installer called %d times, want 1", f.installCalls)
}
if f.lastCommand != "brew upgrade --cask entire" {
t.Errorf("installer got %q, want brew upgrade --cask entire", f.lastCommand)
}
if !strings.Contains(buf.String(), "Update complete") {
t.Errorf("missing success message: %q", buf.String())
}
}

func TestMaybeAutoUpdate_InstallerFailurePrintedToUser(t *testing.T) {
f := newAutoUpdateFixture(t)
useBrewExecutable(t)
f.installErr = errors.New("boom")

var buf bytes.Buffer
MaybeAutoUpdate(context.Background(), &buf, "1.0.0")

if f.installCalls != 1 {
t.Fatalf("installer called %d times, want 1", f.installCalls)
}
if !strings.Contains(buf.String(), "Update failed") {
t.Errorf("missing failure message: %q", buf.String())
}
}
17 changes: 5 additions & 12 deletions cmd/entire/cli/versioncheck/versioncheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ func CheckAndNotify(ctx context.Context, w io.Writer, currentVersion string) {
return
}

// Show notification if outdated
// Show notification and offer an interactive upgrade when outdated
if isOutdated(currentVersion, latestVersion) {
printNotification(w, currentVersion, latestVersion)
MaybeAutoUpdate(ctx, w, currentVersion)
Comment thread
gtrrz-victor marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -173,10 +174,8 @@ func saveCache(cache *VersionCache) error {
return nil
}

// fetchLatestVersion fetches the latest version from the GitHub API.
// Returns a timeout-safe version check using the configured HTTP timeout.
// fetchLatestVersion fetches the latest stable version tag from the GitHub API.
func fetchLatestVersion(ctx context.Context) (string, error) {
// Create a context with timeout for the HTTP request
ctx, cancel := context.WithTimeout(ctx, httpTimeout)
defer cancel()

Expand All @@ -185,7 +184,6 @@ func fetchLatestVersion(ctx context.Context) (string, error) {
return "", fmt.Errorf("creating request: %w", err)
}

// Set headers to identify as Entire CLI
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "entire-cli")

Expand All @@ -200,18 +198,15 @@ func fetchLatestVersion(ctx context.Context) (string, error) {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

// Read response body (limit to 1MB to prevent memory exhaustion)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}

// Parse GitHub release response
version, err := parseGitHubRelease(body)
if err != nil {
return "", fmt.Errorf("parsing release: %w", err)
}

return version, nil
}

Expand All @@ -223,7 +218,7 @@ func isNightly(version string) bool {
return strings.Contains(semver.Prerelease(version), "nightly")
}

// fetchLatestNightlyVersion fetches the latest nightly version from the GitHub releases list.
// fetchLatestNightlyVersion fetches the latest nightly version tag from the GitHub releases list.
func fetchLatestNightlyVersion(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, httpTimeout)
defer cancel()
Expand Down Expand Up @@ -266,20 +261,18 @@ func fetchLatestNightlyVersion(ctx context.Context) (string, error) {
return "", errors.New("no nightly release found")
}

// parseGitHubRelease parses the GitHub API response and extracts the latest stable version.
// parseGitHubRelease parses the GitHub API response and returns the latest stable version tag.
// Filters out prerelease versions.
func parseGitHubRelease(body []byte) (string, error) {
var release GitHubRelease
if err := json.Unmarshal(body, &release); err != nil {
return "", fmt.Errorf("parsing JSON: %w", err)
}

// Skip prerelease versions
if release.Prerelease {
return "", errors.New("only prerelease versions available")
}

// Ensure we have a tag name
if release.TagName == "" {
return "", errors.New("empty tag name")
}
Expand Down
9 changes: 7 additions & 2 deletions cmd/entire/cli/versioncheck/versioncheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ func TestParseGitHubRelease(t *testing.T) {
}

func TestUpdateCommand(t *testing.T) {
const plainBinPath = "/usr/local/bin/entire"
tests := []struct {
name string
currentVersion string
Expand Down Expand Up @@ -355,13 +356,13 @@ func TestUpdateCommand(t *testing.T) {
{
name: "unknown path stable falls back to stable curl command",
currentVersion: "1.0.0",
execPath: func() (string, error) { return "/usr/local/bin/entire", nil },
execPath: func() (string, error) { return plainBinPath, nil },
want: "curl -fsSL https://entire.io/install.sh | bash",
},
{
name: "unknown path nightly falls back to nightly curl command",
currentVersion: "1.0.1-nightly.202604101200.abc1234",
execPath: func() (string, error) { return "/usr/local/bin/entire", nil },
execPath: func() (string, error) { return plainBinPath, nil },
want: "curl -fsSL https://entire.io/install.sh | bash -s -- --channel nightly",
},
{
Expand Down Expand Up @@ -393,6 +394,10 @@ func setupCheckAndNotifyTest(t *testing.T, serverURL string) (*cobra.Command, *b
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

// Prevent MaybeAutoUpdate from opening an interactive prompt when the
// test binary runs in a real terminal (dev laptop, not just CI).
t.Setenv("ENTIRE_TEST_TTY", "0")

origURL := githubAPIURL
githubAPIURL = serverURL
t.Cleanup(func() { githubAPIURL = origURL })
Expand Down
41 changes: 41 additions & 0 deletions docs/architecture/auto-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Auto-Update

After the Entire CLI's daily version check detects a newer release, the
standard notification is followed by an interactive Y/N prompt to run the
installer.

## UX

```
A newer version of Entire CLI is available: v1.2.3 (current: v1.0.0)
Run 'brew upgrade --cask entire' to update.

? Install the new version now? (Y/n)
```

- Declining simply skips the upgrade. The 24-hour version-check cache means
the prompt will not reappear until the next day.
- The installer command is whatever `versioncheck.updateCommand(current)`
returns — `brew upgrade --cask ...`, `mise upgrade entire`,
`scoop update entire/cli`, or the curl-pipe-bash fallback — including the
`--channel nightly` variant for nightly builds.
- stdin, stdout and stderr are wired through so the user sees installer
output and can answer any password prompt.

## Guardrails

The prompt is skipped silently when any of the following holds:

- stdout is not a terminal.
- `CI` environment variable is set.
- `ENTIRE_NO_AUTO_UPDATE` environment variable is set (kill switch).

In those cases the user still sees the existing notification line pointing
to the installer command.

## Not in scope

- No "silent auto-install" mode — the prompt is always interactive.
- No persisted preference — the kill-switch env is the escape hatch.
- No dedicated `entire auto-update` / `entire update` subcommands; the
notification + prompt replaces them.
Loading