diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 461f13d3cc8..6e01cd585c9 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { buildRunPlan, runUninstallPlan, type RunResult } from "./run-plan"; +import { buildRunPlan, type RunResult, runUninstallPlan } from "./run-plan"; function ok(stdout = ""): RunResult { return { status: 0, stdout, stderr: "" }; @@ -77,6 +77,7 @@ describe("uninstall run plan", () => { commandExists: () => true, env: { HOME: "/tmp/nemoclaw-uninstall-test", + NEMOCLAW_AGENT: "", TMPDIR: "/tmp/nemoclaw-uninstall-test", } as NodeJS.ProcessEnv, existsSync: () => false, @@ -206,7 +207,7 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, { - env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv, + env: { HOME: "/tmp/nemoclaw-uninstall-test", NEMOCLAW_AGENT: "" } as NodeJS.ProcessEnv, existsSync: () => false, isTty: true, log: (line) => logs.push(line), @@ -241,6 +242,53 @@ describe("uninstall run plan", () => { expect(run).not.toHaveBeenCalled(); }); + it("explains how to proceed when stdin yields no input at the confirm prompt", () => { + const logs: string[] = []; + const run = vi.fn(); + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + { + env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv, + log: (line) => logs.push(line), + readLine: () => null, + run, + }, + ); + + expect(result.exitCode).toBe(0); + expect(logs).toContain( + "No input available on stdin (closed or non-interactive); re-run with --yes to skip this prompt.", + ); + expect(logs).toContain("Aborted."); + expect(run).not.toHaveBeenCalled(); + }); + + it("builds the default runtime without touching process.stdin (#5188)", () => { + const stdinGet = vi.spyOn(process, "stdin", "get"); + try { + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => false, + env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv, + existsSync: () => false, + kill: () => true, + log: () => {}, + rmSync: vi.fn(), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + // isTty/readLine intentionally not injected: the default + // isStdinTty/readLineFromStdin pair must never instantiate + // process.stdin, which would flip fd 0 non-blocking (#5188). + }, + ); + expect(result.exitCode).toBe(0); + expect(stdinGet).not.toHaveBeenCalled(); + } finally { + stdinGet.mockRestore(); + } + }); + it("kills the Ollama auth proxy via the persisted PID file (#2759)", () => { const logs: string[] = []; const killed: number[] = []; @@ -580,7 +628,14 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: true }, { commandExists: (command) => command !== "docker" && command !== "pgrep", - env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + // Neutralize NEMOCLAW_NON_INTERACTIVE: the runtime merges the real + // process.env, so a developer shell exporting it would silently flip + // this interactive scenario onto the non-interactive path. + env: { + HOME: "/home/test", + NEMOCLAW_NON_INTERACTIVE: "", + TMPDIR: "/tmp/test", + } as NodeJS.ProcessEnv, error: (line) => warnings.push(line), existsSync: (target) => target === "/swapfile" || target === "/home/test/.nemoclaw/managed_swap", @@ -664,7 +719,10 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: true }, { commandExists: () => false, - env: { HOME: tmpHome } as NodeJS.ProcessEnv, + env: { + HOME: tmpHome, + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + } as NodeJS.ProcessEnv, existsSync: tempScopedExistsSync(tmpHome), isTty: false, log: (line) => logs.push(line), @@ -735,7 +793,11 @@ describe("uninstall run plan", () => { { assumeYes: false, deleteModels: false, keepOpenShell: true }, { commandExists: () => false, - env: { HOME: tmpHome } as NodeJS.ProcessEnv, + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + } as NodeJS.ProcessEnv, existsSync: tempScopedExistsSync(tmpHome), isTty: true, log: (line) => logs.push(line), @@ -763,7 +825,11 @@ describe("uninstall run plan", () => { { assumeYes: false, deleteModels: false, keepOpenShell: true }, { commandExists: () => false, - env: { HOME: tmpHome } as NodeJS.ProcessEnv, + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + } as NodeJS.ProcessEnv, existsSync: tempScopedExistsSync(tmpHome), isTty: true, log: (line) => logs.push(line), @@ -799,6 +865,7 @@ describe("uninstall run plan", () => { env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", } as NodeJS.ProcessEnv, existsSync: tempScopedExistsSync(tmpHome), // Simulate a TTY so we exercise the env-var-only branch (the prior @@ -850,7 +917,10 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: true }, { commandExists: () => false, - env: { HOME: tmpHome } as NodeJS.ProcessEnv, + env: { + HOME: tmpHome, + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + } as NodeJS.ProcessEnv, error: (line) => warnings.push(line), existsSync: tempScopedExistsSync(tmpHome), isTty: false, diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index d52d95ab089..9cd60a0578d 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { type AgentBranding, getAgentBranding } from "../../cli/branding"; +import { isStdinTty, readLineFromStdin } from "../../core/stdin"; import { sleepMs } from "../../core/wait"; import { defaultUninstallPaths, @@ -92,23 +93,6 @@ function defaultCommandExists(command: string, env: NodeJS.ProcessEnv): boolean return false; } -function defaultReadLine(): string | null { - const chunks: Buffer[] = []; - const byte = Buffer.alloc(1); - while (true) { - let bytesRead = 0; - try { - bytesRead = fs.readSync(0, byte, 0, 1, null); - } catch { - return chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; - } - if (bytesRead === 0 || byte[0] === 10) break; - chunks.push(Buffer.from(byte)); - } - if (chunks.length === 0) return null; - return Buffer.concat(chunks).toString("utf-8").replace(/\r$/, ""); -} - function splitNonEmptyLines(output: string): string[] { return output .split(/\r?\n/) @@ -254,7 +238,9 @@ function buildRuntime(deps: UninstallRunDeps): UninstallRuntime { env, error: deps.error ?? ((message) => console.error(message)), existsSync: deps.existsSync ?? ((target) => fs.existsSync(target)), - isTty: deps.isTty ?? !!process.stdin.isTTY, + // Side-effect-free TTY check + EAGAIN-tolerant reader; the + // process.stdin/non-blocking-fd hazard is documented in core/stdin.ts. + isTty: deps.isTty ?? isStdinTty(), kill: deps.kill ?? ((pid, signal) => { @@ -266,7 +252,7 @@ function buildRuntime(deps: UninstallRunDeps): UninstallRuntime { } }), log: deps.log ?? ((message) => console.log(message)), - readLine: deps.readLine ?? defaultReadLine, + readLine: deps.readLine ?? readLineFromStdin, rmSync: deps.rmSync ?? fs.rmSync, run: deps.run ?? defaultRun, runDocker: deps.runDocker ?? defaultRunDocker, @@ -311,6 +297,11 @@ function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boole runtime.log("Proceed? [y/N]"); const reply = runtime.readLine(); if (reply && /^(y|yes)$/i.test(reply.trim())) return true; + if (reply === null) { + runtime.log( + "No input available on stdin (closed or non-interactive); re-run with --yes to skip this prompt.", + ); + } runtime.log("Aborted."); return false; } diff --git a/src/lib/core/stdin.test.ts b/src/lib/core/stdin.test.ts new file mode 100644 index 00000000000..7583f8bc0b5 --- /dev/null +++ b/src/lib/core/stdin.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { isStdinTty, readLineFromStdin } from "./stdin"; + +/** + * Scripted fs.readSync stand-in: a single character delivers that byte, 0 + * reports EOF, and any longer string throws an error with that code. + */ +function makeReadSync(events: Array) { + return vi.fn((_fd: number, buffer: Buffer): number => { + const event = events.shift(); + if (event === undefined) throw new Error("readSync called past end of script"); + if (event === 0) return 0; + if (event.length === 1) { + buffer[0] = event.charCodeAt(0); + return 1; + } + const err = new Error(event) as NodeJS.ErrnoException; + err.code = event; + throw err; + }); +} + +describe("readLineFromStdin", () => { + it("retries on EAGAIN until input arrives instead of treating it as EOF (#5020)", () => { + const sleep = vi.fn(); + const readSync = makeReadSync(["EAGAIN", "EAGAIN", "y", "\n"]); + + expect(readLineFromStdin({ readSync, sleep })).toBe("y"); + expect(sleep).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(25); + }); + + it("retries immediately on EINTR without sleeping", () => { + const sleep = vi.fn(); + const readSync = makeReadSync(["EINTR", "n", "\n"]); + + expect(readLineFromStdin({ readSync, sleep })).toBe("n"); + expect(sleep).not.toHaveBeenCalled(); + }); + + it.each([ + ["returns null at immediate EOF", [0], null], + ["strips the trailing CR from CRLF input", ["y", "e", "s", "\r", "\n"], "yes"], + ["returns buffered bytes when EOF arrives before a newline", ["y", 0], "y"], + ["returns null on a hard error with no buffered bytes", ["EBADF"], null], + ["returns buffered bytes when a hard error interrupts mid-line", ["y", "e", "EBADF"], "ye"], + ] as const)("%s", (_label, events, expected) => { + expect(readLineFromStdin({ readSync: makeReadSync([...events]), sleep: vi.fn() })).toBe( + expected, + ); + }); + + it.each([ + ["EAGAIN"], + ["EWOULDBLOCK"], + ] as const)("gives up with null after the non-TTY deadline on persistent %s", (code) => { + const sleep = vi.fn(); + const readSync = vi.fn((): number => { + const err = new Error(code) as NodeJS.ErrnoException; + err.code = code; + throw err; + }); + + expect(readLineFromStdin({ isTty: () => false, readSync, sleep })).toBeNull(); + // 10s virtual deadline at 25ms per retry = exactly 400 bounded waits. + expect(sleep).toHaveBeenCalledTimes(400); + }); + + it("keeps waiting past the deadline on a TTY until input arrives", () => { + const sleep = vi.fn(); + const events = [...Array.from({ length: 450 }, () => "EAGAIN"), "y", "\n"]; + + expect(readLineFromStdin({ isTty: () => true, readSync: makeReadSync(events), sleep })).toBe( + "y", + ); + expect(sleep).toHaveBeenCalledTimes(450); + }); +}); + +describe("isStdinTty", () => { + it("reports false when stdin is not a terminal", () => { + // Vitest workers run with piped stdio, so fd 0 is never a TTY here. + expect(isStdinTty()).toBe(false); + }); +}); diff --git a/src/lib/core/stdin.ts b/src/lib/core/stdin.ts new file mode 100644 index 00000000000..b3ae88cddc8 --- /dev/null +++ b/src/lib/core/stdin.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Synchronous stdin primitives for interactive CLI prompts. + * + * `process.stdin` is hazardous in synchronous CLIs: the getter instantiates + * the stdin stream, and libuv flips fd 0 into non-blocking mode as a + * process-wide side effect. Any later raw `fs.readSync(0)` then throws + * `EAGAIN` instead of blocking for input (#5188, regressed by #5020). The + * helpers here avoid creating that state and tolerate it when other code in + * the same process has already created it. + * + * Why the `EAGAIN` retry stays even though `isStdinTty()` fixed the local + * source: other CLI paths still instantiate `process.stdin` in this process + * — the onboard TTY probe in `onboard.ts`, and the readline prompts in + * `policy/index.ts` and `onboard/messaging-selector.ts` — so any flow that + * runs one of them before prompting here inherits a non-blocking fd 0. The + * retry keeps this module correct regardless of what ran first. + * + * Removal condition: once every synchronous prompt reads stdin through this + * module and a repo-wide lint guard bans direct `process.stdin` access in + * sync CLI paths, the `EAGAIN`/`EWOULDBLOCK` retry can collapse back to the + * plain EOF-on-error behavior. + */ + +import fs from "node:fs"; +import tty from "node:tty"; + +import { isErrnoException } from "./errno"; +import { sleepMs } from "./wait"; + +/** + * True when fd 0 is an interactive terminal. Asks the kernel directly — + * never use `process.stdin.isTTY` for this (see module comment). + */ +export function isStdinTty(): boolean { + return tty.isatty(0); +} + +/** + * Pause between retries while fd 0 reports `EAGAIN` (non-blocking stdin with + * no input yet). Short enough to be imperceptible at an interactive prompt; + * long enough to avoid a hot loop while waiting for keystrokes. + */ +const READ_LINE_RETRY_DELAY_MS = 25; + +/** + * How long a non-TTY stdin may stay in the `EAGAIN` "no input yet" state + * before the read gives up and reports no input. On a TTY the prompt waits + * indefinitely — a human may answer at any time and Ctrl-C always escapes — + * but a non-interactive stdin left non-blocking (e.g. a never-written pipe) + * may never become ready, and a confirm prompt hanging forever in + * automation is an operator hazard. + */ +const NON_TTY_EAGAIN_DEADLINE_MS = 10_000; + +export interface ReadLineDeps { + isTty?: () => boolean; + readSync?: ( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: number | null, + ) => number; + sleep?: (ms: number) => void; +} + +/** + * Read one line from fd 0 directly rather than via `process.stdin` (see + * module comment). A non-blocking fd must be tolerated here: `EAGAIN` means + * "no input yet", not end-of-input — wait briefly and retry. Treating + * `EAGAIN` as EOF (#5020) made every confirm prompt auto-abort on Linux + * TTYs before the user could type. On a TTY the wait is unbounded; on a + * non-TTY stdin it is capped at `NON_TTY_EAGAIN_DEADLINE_MS` so automation + * never hangs on a permanently non-ready fd. + */ +export function readLineFromStdin(deps: ReadLineDeps = {}): string | null { + const readSync = deps.readSync ?? fs.readSync; + const sleep = deps.sleep ?? sleepMs; + const stdinIsTerminal = (deps.isTty ?? isStdinTty)(); + const chunks: Buffer[] = []; + const byte = Buffer.alloc(1); + // Virtual elapsed time: advanced by the nominal retry delay rather than a + // wall clock so the deadline stays deterministic under an injected sleep. + let eagainWaitMs = 0; + while (true) { + let bytesRead = 0; + try { + bytesRead = readSync(0, byte, 0, 1, null); + } catch (err) { + const code = isErrnoException(err) ? err.code : undefined; + if (code === "EAGAIN" || code === "EWOULDBLOCK") { + if (!stdinIsTerminal && eagainWaitMs >= NON_TTY_EAGAIN_DEADLINE_MS) { + return chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + } + sleep(READ_LINE_RETRY_DELAY_MS); + eagainWaitMs += READ_LINE_RETRY_DELAY_MS; + continue; + } + if (code === "EINTR") continue; + return chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + } + if (bytesRead === 0 || byte[0] === 10) break; + chunks.push(Buffer.from(byte)); + } + if (chunks.length === 0) return null; + return Buffer.concat(chunks).toString("utf-8").replace(/\r$/, ""); +} diff --git a/test/fixtures/uninstall-prompt-pty-driver.ts b/test/fixtures/uninstall-prompt-pty-driver.ts new file mode 100644 index 00000000000..d94c3dddc83 --- /dev/null +++ b/test/fixtures/uninstall-prompt-pty-driver.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Process-level driver for the uninstall confirm-prompt pty tests. Loaded by +// test/uninstall-prompt-pty.test.ts via `tsx ` under `script -qec` +// (a pseudo-TTY), so fd 0 is a real terminal device; not picked up by +// Vitest's discovery (lives under test/fixtures/, which is excluded from the +// test glob). +// +// Why subprocess: #5188 lives at the OS layer — fd 0 flipped non-blocking by +// the `process.stdin` getter (libuv side effect) — which cannot be reproduced +// inside a Vitest worker, whose stdin is a pipe the pool has already touched. +// This driver runs `runUninstallPlan` with its REAL default readLine/isTty +// runtime (the units fixed for #5188) while stubbing every destructive +// dependency to a no-op, so a typed "y" walks the full plan without touching +// the host. Scenario flags: +// PTY_DRIVER_POISON_STDIN=1 touch `process.stdin` first, flipping fd 0 +// non-blocking — the exact regression condition. +// PTY_DRIVER_PRESERVABLE=1 pretend ~/.nemoclaw user data exists so the +// second "Also remove them? [y/N]" prompt runs. +// +// Accepts and ignores argv so `bash uninstall.sh` can exec it through its +// NEMOCLAW_NODE/NEMOCLAW_CLI_JS overrides (`internal uninstall run-plan`). +// Refs #5188, #5020, #5163. + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import type { RunResult } from "../../src/lib/actions/uninstall/run-plan"; + +// tsx executes this entry as ESM while the CLI sources compile as CommonJS, +// so a static named import cannot see the CJS exports. `createRequire` loads +// the module through tsx's CJS hook instead — same approach as +// strict-tool-call-probe-driver.ts. +const require = createRequire(import.meta.url); +const { runUninstallPlan } = + require("../../src/lib/actions/uninstall/run-plan") as typeof import("../../src/lib/actions/uninstall/run-plan"); + +if (process.env.PTY_DRIVER_POISON_STDIN === "1") { + void process.stdin.isTTY; +} + +const preservable = process.env.PTY_DRIVER_PRESERVABLE === "1"; +const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pty-driver-")); +const okResult: RunResult = { status: 0, stdout: "", stderr: "" }; + +const { exitCode } = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => false, + // Hermetic env: the runtime merges the real process.env, so a developer + // shell exporting NEMOCLAW_* knobs (non-interactive mode, destroy-user- + // data acknowledgement, agent branding) would change which prompts run + // and what they print. Pin them empty so scenarios behave identically on + // every machine. + env: { + HOME: home, + NEMOCLAW_AGENT: "", + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + TMPDIR: home, + } as NodeJS.ProcessEnv, + existsSync: (target) => preservable && target.includes(".nemoclaw"), + kill: () => true, + rmSync: (() => {}) as never, + run: () => okResult, + runDocker: () => okResult, + // readLine and isTty are deliberately NOT injected: the default + // readLineFromStdin/isStdinTty pair reading the pty is what is under test. + }, +); +process.exit(exitCode); diff --git a/test/uninstall-prompt-pty.test.ts b/test/uninstall-prompt-pty.test.ts new file mode 100644 index 00000000000..d9a1881214a --- /dev/null +++ b/test/uninstall-prompt-pty.test.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, spawnSync } from "node:child_process"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { execTimeout, testTimeoutOptions } from "./helpers/timeouts"; + +// Deterministic runtime validation for #5188: the uninstall confirm prompts +// must wait for typed input on a real terminal even when fd 0 is already +// non-blocking (the libuv side effect of touching `process.stdin` that made +// every prompt auto-abort after #5020). Vitest workers cannot reproduce this +// — their stdin is a pipe the pool has already touched — so each case drives +// test/fixtures/uninstall-prompt-pty-driver.ts in a fresh child under +// `script -qec`, which allocates a pseudo-TTY for fd 0. The driver runs the +// real default readLine/isTty runtime with all destructive deps stubbed. +// +// Linux-only: `script -qec` is util-linux; macOS ships BSD script with a +// different CLI. CI runs on Linux. Refs #5188, #5020, #5163. + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const TSX = path.join(REPO_ROOT, "node_modules", ".bin", "tsx"); +const DRIVER = path.join(import.meta.dirname, "fixtures", "uninstall-prompt-pty-driver.ts"); +const UNINSTALL_SH = path.join(REPO_ROOT, "uninstall.sh"); + +const ptySupported = + process.platform === "linux" && + spawnSync("script", ["--version"], { stdio: "ignore" }).status === 0; + +interface PtyRun { + exited: Promise; + isAlive: () => boolean; + output: () => string; + waitForOutput: (text: string) => Promise; + write: (data: string) => void; +} + +function spawnUnderPty(command: string, extraEnv: Record = {}): PtyRun { + const child = spawn("script", ["-qec", command, "/dev/null"], { + cwd: REPO_ROOT, + env: { ...process.env, ...extraEnv }, + stdio: ["pipe", "pipe", "inherit"], + }); + // A failed scenario can exit before we write the answer; swallow the EPIPE + // so the assertion failure (not a crash) reports the problem. + child.stdin.on("error", () => {}); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + const exited = new Promise((resolve) => child.on("close", resolve)); + return { + exited, + isAlive: () => child.exitCode === null, + output: () => stdout, + waitForOutput: async (text: string) => { + const deadline = Date.now() + execTimeout(15_000); + while (Date.now() < deadline) { + if (stdout.includes(text)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for ${JSON.stringify(text)}; output so far:\n${stdout}`); + }, + write: (data: string) => { + child.stdin.write(data); + }, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe.runIf(ptySupported)("uninstall confirm prompts under a pseudo-TTY (#5188)", () => { + it( + "waits for a typed y on a poisoned non-blocking fd 0 and proceeds", + testTimeoutOptions(30_000), + async () => { + const pty = spawnUnderPty(`${TSX} ${DRIVER}`, { PTY_DRIVER_POISON_STDIN: "1" }); + await pty.waitForOutput("Proceed? [y/N]"); + await sleep(500); + // The #5188 regression aborted within ~1ms of printing the prompt; the + // run must still be alive, blocked on input, half a second later. + expect(pty.isAlive()).toBe(true); + pty.write("y\n"); + expect(await pty.exited).toBe(0); + expect(pty.output()).toContain("Claws retracted. Until next time."); + }, + ); + + it("aborts without running the plan on a typed n", testTimeoutOptions(30_000), async () => { + const pty = spawnUnderPty(`${TSX} ${DRIVER}`, { PTY_DRIVER_POISON_STDIN: "1" }); + await pty.waitForOutput("Proceed? [y/N]"); + await sleep(500); + expect(pty.isAlive()).toBe(true); + pty.write("n\n"); + expect(await pty.exited).toBe(0); + expect(pty.output()).toContain("Aborted."); + // No plan step (`[1/6] ...`) may run on the decline path. + expect(pty.output()).not.toContain("[1/"); + }); + + it( + "waits at the second user-data prompt and keeps data on a typed n", + testTimeoutOptions(30_000), + async () => { + const pty = spawnUnderPty(`${TSX} ${DRIVER}`, { + PTY_DRIVER_POISON_STDIN: "1", + PTY_DRIVER_PRESERVABLE: "1", + }); + await pty.waitForOutput("Proceed? [y/N]"); + await sleep(300); + pty.write("y\n"); + await pty.waitForOutput("Also remove them? [y/N]"); + await sleep(300); + expect(pty.isAlive()).toBe(true); + pty.write("n\n"); + expect(await pty.exited).toBe(0); + expect(pty.output()).toContain("Keeping user data."); + expect(pty.output()).toContain("Claws retracted. Until next time."); + }, + ); + + it( + "reaches a waiting prompt through the bash uninstall.sh wrapper", + testTimeoutOptions(30_000), + async () => { + const pty = spawnUnderPty(`bash ${UNINSTALL_SH}`, { + NEMOCLAW_CLI_JS: DRIVER, + NEMOCLAW_NODE: TSX, + }); + await pty.waitForOutput("Proceed? [y/N]"); + await sleep(500); + expect(pty.isAlive()).toBe(true); + pty.write("n\n"); + expect(await pty.exited).toBe(0); + expect(pty.output()).toContain("Aborted."); + }, + ); +});