diff --git a/src/commands/onboard-interactive.e2e.test.ts b/src/commands/onboard-interactive.e2e.test.ts index a1b4c9420..9b2e0e858 100644 --- a/src/commands/onboard-interactive.e2e.test.ts +++ b/src/commands/onboard-interactive.e2e.test.ts @@ -47,7 +47,7 @@ describe("runInteractiveOnboarding", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { - resumeStdin: false, + resumeStdinIfPaused: false, }); }); @@ -59,7 +59,7 @@ describe("runInteractiveOnboarding", () => { expect(runtime.exit).not.toHaveBeenCalled(); expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { - resumeStdin: false, + resumeStdinIfPaused: false, }); }); }); diff --git a/src/commands/onboard-interactive.test.ts b/src/commands/onboard-interactive.test.ts index f4eb06070..4a1dbb44f 100644 --- a/src/commands/onboard-interactive.test.ts +++ b/src/commands/onboard-interactive.test.ts @@ -41,7 +41,7 @@ describe("runInteractiveOnboarding", () => { expect(mocks.runOnboardingWizard).toHaveBeenCalledOnce(); expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { - resumeStdin: false, + resumeStdinIfPaused: false, }); }); @@ -60,7 +60,7 @@ describe("runInteractiveOnboarding", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { - resumeStdin: false, + resumeStdinIfPaused: false, }); const restoreOrder = mocks.restoreTerminalState.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 4041a1176..2678f2810 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -23,7 +23,7 @@ export async function runInteractiveOnboarding( throw err; } finally { // Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup). - restoreTerminalState("onboarding finish", { resumeStdin: false }); + restoreTerminalState("onboarding finish", { resumeStdinIfPaused: false }); if (exitCode !== null) { runtime.exit(exitCode); } diff --git a/src/runtime.ts b/src/runtime.ts index 6cd0850e5..95d73e63d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -31,7 +31,7 @@ export const defaultRuntime: RuntimeEnv = { console.error(...args); }, exit: (code) => { - restoreTerminalState("runtime exit", { resumeStdin: false }); + restoreTerminalState("runtime exit", { resumeStdinIfPaused: false }); process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked }, diff --git a/src/terminal/restore.test.ts b/src/terminal/restore.test.ts index ea5e65681..5f79f2732 100644 --- a/src/terminal/restore.test.ts +++ b/src/terminal/restore.test.ts @@ -58,9 +58,26 @@ describe("restoreTerminalState", () => { (process.stdin as { resume?: () => void }).resume = resume; (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; - restoreTerminalState("test", { resumeStdin: true }); + restoreTerminalState("test", { resumeStdinIfPaused: true }); expect(setRawMode).toHaveBeenCalledWith(false); expect(resume).toHaveBeenCalledOnce(); }); + + it("does not touch stdin when stdin is not a TTY", () => { + const setRawMode = vi.fn(); + const resume = vi.fn(); + const isPaused = vi.fn(() => true); + + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = setRawMode; + (process.stdin as { resume?: () => void }).resume = resume; + (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; + + restoreTerminalState("test", { resumeStdinIfPaused: true }); + + expect(setRawMode).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + }); }); diff --git a/src/terminal/restore.ts b/src/terminal/restore.ts index 8494c4477..e2ddd3f25 100644 --- a/src/terminal/restore.ts +++ b/src/terminal/restore.ts @@ -10,6 +10,13 @@ type RestoreTerminalStateOptions = { * Default: false (safer for "cleanup then exit" call sites). */ resumeStdin?: boolean; + + /** + * Alias for resumeStdin. Prefer this name to make the behavior explicit. + * + * Default: false. + */ + resumeStdinIfPaused?: boolean; }; function reportRestoreFailure(scope: string, err: unknown, reason?: string): void { @@ -26,7 +33,9 @@ export function restoreTerminalState( reason?: string, options: RestoreTerminalStateOptions = {}, ): void { - const resumeStdin = options.resumeStdin ?? false; + // Docker TTY note: resuming stdin can keep a container process alive even + // after the wizard is "done" (stdin_open: true), making installers appear hung. + const resumeStdin = options.resumeStdinIfPaused ?? options.resumeStdin ?? false; try { clearActiveProgressLine(); } catch (err) { diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b5840e45a..b73979915 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -329,7 +329,7 @@ export async function finalizeOnboardingWizard( }); if (hatchChoice === "tui") { - restoreTerminalState("pre-onboarding tui", { resumeStdin: true }); + restoreTerminalState("pre-onboarding tui", { resumeStdinIfPaused: true }); await runTui({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined,