From 59bb7d987e274f5b6d32732c2974974a38b9c35f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:11:12 -0700 Subject: [PATCH] fix(miner): fail fast instead of hanging when init --interactive has no TTY init --interactive's first prompt blocks forever on a stdin that can never receive a real line of input -- a plain `ssh host "loopover- miner init --interactive"` with no allocated pty, or any other no-TTY/CI invocation, previously just hung until something external killed the process. createWizardIo now exposes isInteractive (the real input stream's own isTTY), and runInteractiveInit checks it before issuing any prompt: a non-interactive io fails fast with a clear message pointing at the env-var-only setup path (GITHUB_TOKEN, MINER_CODING_AGENT_PROVIDER, ANTHROPIC_API_KEY/OPENAI_API_KEY) recommended for unattended/fleet deployments, then loopover-miner doctor to verify -- matching #6845's recommendation. Closes #6846 --- packages/loopover-miner/lib/init-wizard.d.ts | 6 ++- packages/loopover-miner/lib/init-wizard.js | 19 ++++++++ test/unit/miner-init-wizard.test.ts | 47 +++++++++++++++++--- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/loopover-miner/lib/init-wizard.d.ts b/packages/loopover-miner/lib/init-wizard.d.ts index 2ab91daee5..6f3c02791f 100644 --- a/packages/loopover-miner/lib/init-wizard.d.ts +++ b/packages/loopover-miner/lib/init-wizard.d.ts @@ -3,6 +3,10 @@ export type WizardIo = { promptMasked(question: string): Promise; writeLine(text: string): void; close?: () => void; + /** Whether this `io`'s underlying input is a real, interactive terminal. Optional -- a fake test `io` that + * omits it is treated as interactive (matching every pre-#6846 test's existing behavior); `createWizardIo`'s + * real adapter always sets it from the actual stream's `isTTY`. */ + isInteractive?: boolean; }; export type RunInteractiveInitOptions = { @@ -30,4 +34,4 @@ export function runInteractiveInit( export function createWizardIo( input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, -): WizardIo & { close: () => void }; +): WizardIo & { close: () => void; isInteractive: boolean }; diff --git a/packages/loopover-miner/lib/init-wizard.js b/packages/loopover-miner/lib/init-wizard.js index b944ef16a0..df103336f5 100644 --- a/packages/loopover-miner/lib/init-wizard.js +++ b/packages/loopover-miner/lib/init-wizard.js @@ -133,6 +133,20 @@ export async function promptCompanionVars(io, provider) { * injected so tests never make a real network call or wait on a real timer during device-flow polling. */ export async function runInteractiveInit(env, cwd, io, options = {}) { + // #6846: fail fast, not silently forever. `io.isInteractive` is only ever `false` for a real + // `createWizardIo()` adapter over a non-TTY stdin (a test's fake `io` has no such field and stays + // interactive by default, so every existing test is unaffected) -- an operator running this over a + // no-pty SSH session or a CI/fleet script gets clear, actionable guidance instead of a hang on the + // wizard's first prompt, which can never receive a real line of input. + if (io.isInteractive === false) { + io.writeLine("init --interactive requires a real terminal (no TTY detected on stdin)."); + io.writeLine("For an unattended/fleet setup, skip this wizard and set these env vars directly instead:"); + io.writeLine(" - GITHUB_TOKEN (your GitHub credential)"); + io.writeLine(" - MINER_CODING_AGENT_PROVIDER (claude-cli or codex-cli)"); + io.writeLine(" - ANTHROPIC_API_KEY for claude-cli, or OPENAI_API_KEY for codex-cli"); + io.writeLine("Then verify with: loopover-miner doctor"); + return 3; + } const githubToken = await collectGithubToken(io, env, options); const provider = await promptProviderSelection(io); @@ -181,6 +195,11 @@ export function createWizardIo(input = process.stdin, output = process.stdout) { originalWriteToOutput(masking ? "*" : stringToWrite); }; return { + // #6846: whether `input` is a real, interactive terminal -- `runInteractiveInit` checks this BEFORE + // issuing its first prompt, so a no-TTY invocation (piped stdin, a plain `ssh host "loopover-miner init + // --interactive"` with no allocated pty) fails fast with actionable guidance instead of hanging forever + // on a `readline` prompt that can never receive a real line of input. + isInteractive: Boolean(input.isTTY), promptText(question) { return new Promise((resolve) => rl.question(question, resolve)); }, diff --git a/test/unit/miner-init-wizard.test.ts b/test/unit/miner-init-wizard.test.ts index 6dd74c8d79..75c517b49e 100644 --- a/test/unit/miner-init-wizard.test.ts +++ b/test/unit/miner-init-wizard.test.ts @@ -26,12 +26,15 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function createFakeIo(options: { maskedAnswers?: string[]; textAnswers?: string[] } = {}) { +function createFakeIo(options: { maskedAnswers?: string[]; textAnswers?: string[]; isInteractive?: boolean } = {}) { const lines: string[] = []; const maskedQueue = [...(options.maskedAnswers ?? [])]; const textQueue = [...(options.textAnswers ?? [])]; return { lines, + // Defaults to `true` (a real terminal) -- every pre-#6846 test exercises the interactive flow and never + // sets this, so the default must match that existing, unchanged behavior. + isInteractive: options.isInteractive ?? true, async promptMasked(question: string) { lines.push(`MASKED?${question}`); return maskedQueue.shift() ?? ""; @@ -152,6 +155,22 @@ describe("loopover-miner init --interactive wizard (#5176)", () => { expect(exitCode).toBe(0); }); + it("#6846: fails fast with actionable guidance instead of prompting when stdin isn't a real TTY", async () => { + const stateDir = join(tempRoot(), "state"); + const cwd = tempRoot(); + const env = { LOOPOVER_MINER_CONFIG_DIR: stateDir }; + const io = createFakeIo({ isInteractive: false }); + + const exitCode = await runInteractiveInit(env, cwd, io); + + expect(exitCode).toBe(3); + // Never reaches a prompt (would hang forever on a real no-TTY stdin) -- no MASKED?/TEXT? lines recorded. + expect(io.lines.some((line) => line.startsWith("MASKED?") || line.startsWith("TEXT?"))).toBe(false); + expect(io.lines.some((line) => line.includes("requires a real terminal"))).toBe(true); + expect(io.lines.some((line) => line.includes("ANTHROPIC_API_KEY"))).toBe(true); + expect(existsSync(join(stateDir, ".env"))).toBe(false); + }); + it("re-prompts when the token is left empty before accepting a valid one", async () => { const stateDir = join(tempRoot(), "state"); const cwd = tempRoot(); @@ -269,6 +288,19 @@ describe("loopover-miner init --interactive wizard (#5176)", () => { }); describe("createWizardIo (real terminal adapter, driven over fake streams)", () => { + it("#6846: isInteractive reflects the real input stream's isTTY, in both directions", () => { + const { inStream: ttyIn, outStream: ttyOut } = createFakeTty(); + const ttyIo = createWizardIo(ttyIn, ttyOut); + expect(ttyIo.isInteractive).toBe(true); + ttyIo.close(); + + const { outStream: nonTtyOut } = createFakeTty(); + const nonTtyIn = new Readable({ read() {} }); // no isTTY set -- mirrors a piped/no-pty stdin + const nonTtyIo = createWizardIo(nonTtyIn, nonTtyOut); + expect(nonTtyIo.isInteractive).toBe(false); + nonTtyIo.close(); + }); + it("promptText resolves the typed line", async () => { const { inStream, outStream } = createFakeTty(); const io = createWizardIo(inStream, outStream); @@ -316,13 +348,16 @@ describe("loopover-miner init --interactive wizard (#5176)", () => { }); it("e2e: `loopover-miner init --interactive` dispatches to the wizard, not the non-interactive path", () => { - // No stdin input is piped, so the wizard blocks on its first prompt and the process is torn down once - // Node detects the unsettled top-level await -- this only asserts the CLI routes `--interactive` to the - // wizard (distinct prompt text, distinct code path) without hanging; the full multi-turn prompt flow is - // exercised precisely and deterministically by the direct runInteractiveInit tests above. + // No stdin input is piped, and a spawned child process never has a real TTY on its stdin either way -- + // pre-#6846, this meant the wizard blocked forever on its first prompt (see that fix's own tests for the + // hang this eliminates); now it fails fast with the wizard's own real-terminal-required message. That + // message is itself proof the CLI routed `--interactive` to the wizard (distinct code path, distinct + // output) rather than the plain, non-interactive `init` path -- this test's actual assertion -- so it + // still exercises exactly what it always intended to, just without the hang. The full multi-turn prompt + // flow is exercised precisely and deterministically by the direct runInteractiveInit tests above. const stateDir = tempRoot(); const result = runCliResult(["init", "--interactive"], { LOOPOVER_MINER_CONFIG_DIR: stateDir }); - expect(result.output).toContain("GitHub token (input hidden)"); + expect(result.output).toContain("requires a real terminal"); expect(result.output).not.toContain("initialized " + stateDir); }); });