Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion packages/loopover-miner/lib/init-wizard.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ export type WizardIo = {
promptMasked(question: string): Promise<string>;
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 = {
Expand Down Expand Up @@ -30,4 +34,4 @@ export function runInteractiveInit(
export function createWizardIo(
input?: NodeJS.ReadableStream,
output?: NodeJS.WritableStream,
): WizardIo & { close: () => void };
): WizardIo & { close: () => void; isInteractive: boolean };
19 changes: 19 additions & 0 deletions packages/loopover-miner/lib/init-wizard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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));
},
Expand Down
47 changes: 41 additions & 6 deletions test/unit/miner-init-wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? "";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});