Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion cmd/entire/cli/integration_test/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ func (env *TestEnv) RunCommandInteractive(args []string, respond func(ptyFile *o
cmd.Env = append(testutil.GitIsolatedEnv(),
"ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir,
"TERM=xterm",
"ACCESSIBLE=1", // Required: makes huh read from stdin instead of /dev/tty
"ACCESSIBLE=1", // Required: makes huh read from stdin instead of /dev/tty
"ENTIRE_TEST_TTY=1", // Force CanPromptInteractively()=true: the subprocess has a real pty but may inherit CI=true from the runner, which would otherwise short-circuit the interactive gate.
)

// Start command with a pty
Expand Down
11 changes: 10 additions & 1 deletion cmd/entire/cli/interactive/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
// actual environment, so tests that need a specific answer should set
// ENTIRE_TEST_TTY explicitly rather than assume a non-interactive host.
func CanPromptInteractively() bool {
if v, ok := os.LookupEnv("ENTIRE_TEST_TTY"); ok {
if v := os.Getenv("ENTIRE_TEST_TTY"); v != "" {
return v == "1"
}

Expand All @@ -43,6 +43,15 @@ func CanPromptInteractively() bool {
return false
}

// CI=<non-empty> is the de-facto CI-provider convention (GitHub Actions,
// CircleCI, GitLab, Travis, Buildkite). Self-hosted runners expose /dev/tty,
// so the probe below isn't enough — an interactive prompt on CI hangs.
// CI=false is the `is-ci` escape hatch for developers who need to override
// an inherited value.
if v := os.Getenv("CI"); v != "" && v != "false" {
return false
}

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return false
Expand Down
20 changes: 20 additions & 0 deletions cmd/entire/cli/interactive/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ func TestCanPromptInteractively_AgentEnvGuards(t *testing.T) {
}
}

func TestCanPromptInteractively_CIEnv(t *testing.T) {
t.Setenv("ENTIRE_TEST_TTY", "")
_ = os.Unsetenv("ENTIRE_TEST_TTY")
t.Setenv("CI", "true")
if CanPromptInteractively() {
t.Error("CanPromptInteractively() = true; want false when CI=true")
}
}

// CI=false is the `is-ci` escape hatch: a dev may set it to override an
// inherited CI=true. Verify the CI branch doesn't short-circuit to false,
// using ENTIRE_TEST_TTY=1 to stand in for a real TTY in the test host.
func TestCanPromptInteractively_CIFalseOverride(t *testing.T) {
t.Setenv("CI", "false")
t.Setenv("ENTIRE_TEST_TTY", "1")
if !CanPromptInteractively() {
t.Error("CanPromptInteractively() = false; want true when CI=false")
}
}

func TestIsTerminalWriter_NonFile(t *testing.T) {
t.Parallel()
if IsTerminalWriter(&bytes.Buffer{}) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/setup_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ func ghFlagsProvided(opts GitHubBootstrapOptions) bool {

// confirmCreateGitHubRepo asks the user whether they want to also create
// a matching GitHub repository. Interactive-only; callers gate on
// CanPromptInteractively.
// interactive.CanPromptInteractively.
func confirmCreateGitHubRepo() (bool, error) {
confirmed := true
form := NewAccessibleForm(
Expand Down
112 changes: 112 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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
isTerminalOut = interactive.IsTerminalWriter
)

// 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.
//
// 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
// invocation while an upstream issue (network, auth, repo outage) is
// still in place.
//
// 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.
func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion string) {
// 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() {
fmt.Fprintf(w, "To update, download the latest release from:\n %s\n", downloadsURL)
return
}
if os.Getenv(envKillSwitch) != "" || !interactive.CanPromptInteractively() || !isTerminalOut(w) {
fmt.Fprintf(w, "To update, run:\n %s\n", updateCommand(currentVersion))
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\nTry again later running:\n %s\n", err, cmdStr)
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 == goosWindows {
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
}
Loading
Loading