From 1fb584fa14b0939b826d8dffab9498cf6b0e374d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= Date: Tue, 16 Jun 2026 02:29:52 -0300 Subject: [PATCH] fix(tui): detect missing TTY and show helpful error instead of Bubbletea crash When gentle-ai launches the interactive TUI without a terminal attached (scripts, CI/CD, non-interactive SSH), Bubbletea panics with a confusing 'could not open a new TTY' error. Added a TTY guard that checks stdin before launching the TUI. When no TTY is available, returns a clear error listing non-interactive commands (--version, --help, update, install, sync, doctor) instead of crashing. The guard uses the existing isattyFn seam from selfupdate.go for testability. Info commands (version, help) continue to work without a TTY since they are dispatched before the guard. Closes #95 --- internal/app/app.go | 25 +++++++++++ internal/app/app_test.go | 84 +++++++++++++++++++++++++++++++++++++ internal/app/parity_test.go | 10 ++++- 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9e0d5fb9b..802057bdd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,6 +28,24 @@ import ( // Version is set from main via ldflags at build time. var Version = "dev" +// ErrNoTTYForTUI is returned when the TUI is requested from a non-interactive +// environment (scripts, CI, non-interactive SSH). Lists available non-TUI commands. +var ErrNoTTYForTUI = errors.New( + "gentle-ai: no TTY available — the interactive TUI cannot launch from scripts, CI, or non-interactive SSH.\n" + + "Available non-interactive commands:\n" + + " gentle-ai --version Print version\n" + + " gentle-ai --help Show all commands\n" + + " gentle-ai update Check for available updates\n" + + " gentle-ai install Configure AI coding agents\n" + + " gentle-ai sync Sync agent configs to current version\n" + + " gentle-ai doctor Run ecosystem health diagnostics") + +// stdinIsTerminal reports whether stdin is connected to a terminal. +// Uses the existing isattyFn seam from selfupdate.go for testability. +var stdinIsTerminal = func() bool { + return isattyFn(os.Stdin.Fd()) +} + var ( updateCheckAll = update.CheckAll updateCheckFiltered = update.CheckFiltered @@ -125,6 +143,13 @@ func RunArgs(args []string, stdout io.Writer) error { } } + // TTY guard: the interactive TUI requires a terminal. Non-interactive + // environments (scripts, CI, non-interactive SSH) get a clear error + // listing available non-TUI commands instead of a Bubbletea panic (#95). + if isTUIFlow && !stdinIsTerminal() { + return ErrNoTTYForTUI + } + if len(args) == 0 { homeDir, err := os.UserHomeDir() if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9d0ed7aad..022c8bd82 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1135,11 +1135,13 @@ func TestRunArgs_TUISkipsSelfUpdate(t *testing.T) { origDetect := detectSystem origEnsure := ensureCurrentOSSupported origRunTUI := runTUI + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { selfUpdateFn = origSelfUpdate detectSystem = origDetect ensureCurrentOSSupported = origEnsure runTUI = origRunTUI + stdinIsTerminal = origStdinIsTerminal }) ensureCurrentOSSupported = func() error { return nil } @@ -1147,6 +1149,9 @@ func TestRunArgs_TUISkipsSelfUpdate(t *testing.T) { return system.DetectionResult{System: system.SystemInfo{Supported: true}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } + // Return the same model to avoid nil dereference if RunArgs inspects it. tuiCalled := 0 runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) { @@ -1173,6 +1178,64 @@ func TestRunArgs_TUISkipsSelfUpdate(t *testing.T) { } } +func TestRunArgs_NoTTY_ReturnsErrNoTTYForTUI(t *testing.T) { + // NOTE: modifies package-level vars; must not run in parallel. + origStdinIsTerminal := stdinIsTerminal + t.Cleanup(func() { stdinIsTerminal = origStdinIsTerminal }) + + // Simulate a non-interactive environment (no TTY). + stdinIsTerminal = func() bool { return false } + + var buf bytes.Buffer + err := RunArgs([]string{}, &buf) + if err == nil { + t.Fatal("RunArgs(empty args) with no TTY: expected error, got nil") + } + if !strings.Contains(err.Error(), "no TTY available") { + t.Fatalf("RunArgs(empty args) with no TTY: error = %v; want error containing 'no TTY available'", err) + } + if !strings.Contains(err.Error(), "--version") { + t.Fatalf("RunArgs(empty args) with no TTY: error should list --version command; got %v", err) + } + if !strings.Contains(err.Error(), "--help") { + t.Fatalf("RunArgs(empty args) with no TTY: error should list --help command; got %v", err) + } +} + +func TestRunArgs_NoTTY_StillAllowsVersionAndHelp(t *testing.T) { + // NOTE: modifies package-level vars; must not run in parallel. + origStdinIsTerminal := stdinIsTerminal + t.Cleanup(func() { stdinIsTerminal = origStdinIsTerminal }) + + // Simulate a non-interactive environment (no TTY). + stdinIsTerminal = func() bool { return false } + + tests := []struct { + name string + args []string + }{ + {name: "version", args: []string{"version"}}, + {name: "--version", args: []string{"--version"}}, + {name: "-v", args: []string{"-v"}}, + {name: "help", args: []string{"help"}}, + {name: "--help", args: []string{"--help"}}, + {name: "-h", args: []string{"-h"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + err := RunArgs(tc.args, &buf) + if err != nil { + t.Fatalf("RunArgs(%v) with no TTY: unexpected error = %v", tc.args, err) + } + if buf.Len() == 0 { + t.Fatalf("RunArgs(%v) with no TTY: expected output, got empty buffer", tc.args) + } + }) + } +} + func TestIsExplicitUpdateFlow(t *testing.T) { tests := []struct { name string @@ -1485,10 +1548,12 @@ func TestRunArgs_TUIRestartsAfterGentleAIUpgradeResult(t *testing.T) { origDetect := detectSystem origEnsure := ensureCurrentOSSupported origRunTUI := runTUI + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { detectSystem = origDetect ensureCurrentOSSupported = origEnsure runTUI = origRunTUI + stdinIsTerminal = origStdinIsTerminal unsetEnv(t, envSelfUpdateDone) }) unsetEnv(t, envSelfUpdateDone) @@ -1498,6 +1563,9 @@ func TestRunArgs_TUIRestartsAfterGentleAIUpgradeResult(t *testing.T) { return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } + report := upgrade.UpgradeReport{Results: []upgrade.ToolUpgradeResult{ {ToolName: "gentle-ai", Status: upgrade.UpgradeSucceeded, NewVersion: "v1.40.0"}, }} @@ -1539,12 +1607,14 @@ func TestRunArgs_PendingSync_RunsSyncAndClearsFlag(t *testing.T) { origDetect := detectSystem origRunTUI := runTUI origDeferredSync := deferredSyncFn + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { selfUpdateFn = origSelf ensureCurrentOSSupported = origEnsure detectSystem = origDetect runTUI = origRunTUI deferredSyncFn = origDeferredSync + stdinIsTerminal = origStdinIsTerminal }) selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error { @@ -1554,6 +1624,8 @@ func TestRunArgs_PendingSync_RunsSyncAndClearsFlag(t *testing.T) { detectSystem = func(context.Context) (system.DetectionResult, error) { return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) { return m, nil } @@ -1601,12 +1673,14 @@ func TestRunArgs_PendingSync_LeavesSetOnFailure(t *testing.T) { origDetect := detectSystem origRunTUI := runTUI origDeferredSync := deferredSyncFn + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { selfUpdateFn = origSelf ensureCurrentOSSupported = origEnsure detectSystem = origDetect runTUI = origRunTUI deferredSyncFn = origDeferredSync + stdinIsTerminal = origStdinIsTerminal }) selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error { @@ -1616,6 +1690,8 @@ func TestRunArgs_PendingSync_LeavesSetOnFailure(t *testing.T) { detectSystem = func(context.Context) (system.DetectionResult, error) { return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) { return m, nil } @@ -1677,12 +1753,14 @@ func TestRunArgs_PendingSync_ClearWriteFailureIsLogged(t *testing.T) { origDetect := detectSystem origRunTUI := runTUI origDeferredSync := deferredSyncFn + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { selfUpdateFn = origSelf ensureCurrentOSSupported = origEnsure detectSystem = origDetect runTUI = origRunTUI deferredSyncFn = origDeferredSync + stdinIsTerminal = origStdinIsTerminal }) selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error { @@ -1692,6 +1770,8 @@ func TestRunArgs_PendingSync_ClearWriteFailureIsLogged(t *testing.T) { detectSystem = func(context.Context) (system.DetectionResult, error) { return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) { return m, nil } @@ -1730,12 +1810,14 @@ func TestRunArgs_NoPendingSync_NoSyncCall(t *testing.T) { origDetect := detectSystem origRunTUI := runTUI origDeferredSync := deferredSyncFn + origStdinIsTerminal := stdinIsTerminal t.Cleanup(func() { selfUpdateFn = origSelf ensureCurrentOSSupported = origEnsure detectSystem = origDetect runTUI = origRunTUI deferredSyncFn = origDeferredSync + stdinIsTerminal = origStdinIsTerminal }) selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error { @@ -1745,6 +1827,8 @@ func TestRunArgs_NoPendingSync_NoSyncCall(t *testing.T) { detectSystem = func(context.Context) (system.DetectionResult, error) { return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil } + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) { return m, nil } diff --git a/internal/app/parity_test.go b/internal/app/parity_test.go index 18fed5fd1..8405c939f 100644 --- a/internal/app/parity_test.go +++ b/internal/app/parity_test.go @@ -163,7 +163,15 @@ func TestGuardFlowLinuxDryRunPropagatesDecision(t *testing.T) { func TestRunArgsNoCommandLaunchesTUI(t *testing.T) { origRunTUI := runTUI - t.Cleanup(func() { runTUI = origRunTUI }) + origStdinIsTerminal := stdinIsTerminal + t.Cleanup(func() { + runTUI = origRunTUI + stdinIsTerminal = origStdinIsTerminal + }) + + // Simulate a TTY so the TTY guard doesn't block the TUI flow. + stdinIsTerminal = func() bool { return true } + runTUI = func(m tea.Model, opts ...tea.ProgramOption) (tea.Model, error) { return nil, errors.New("mock TUI error: no TTY") }