From 5895e33437ba1709fbc562266f0f5d2b9100027a Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:48:06 +0000 Subject: [PATCH 1/6] fix(auth): fail the login prompt when stdin ends without an answer --- src/__tests__/utils/auth.test.ts | 54 +++++++++++++++++++++++++++++++- src/utils/auth.ts | 13 ++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index e04c8fdaeb..2452fd6f07 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -3,7 +3,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isAuthenticated } from '../../utils/auth'; +import { PassThrough } from 'stream'; +import { + interactiveLogin, + isAuthenticated, + manualLogin, +} from '../../utils/auth'; import { initializeConfig, resetConfig } from '../../utils/config'; import * as credentials from '../../utils/credentials'; @@ -101,4 +106,51 @@ describe('Authentication Utilities', () => { expect(isAuthenticated()).toBe(true); }); }); + + describe('login prompts without a terminal', () => { + const realStdin = process.stdin; + let stdin: PassThrough; + + beforeEach(() => { + stdin = new PassThrough(); + Object.defineProperty(process, 'stdin', { + value: stdin, + configurable: true, + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + Object.defineProperty(process, 'stdin', { + value: realStdin, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('rejects the login menu when stdin ends without a choice', async () => { + const login = interactiveLogin(); + stdin.end(); + + await expect(login).rejects.toThrow(/No input to log in with/); + }); + + it('rejects manual login when stdin ends without a key', async () => { + const login = manualLogin(); + stdin.end(); + + await expect(login).rejects.toThrow(/No input to log in with/); + }); + + it('still reads a piped API key', async () => { + const login = manualLogin(); + stdin.end('fc-piped-key\n'); + + await expect(login).resolves.toEqual({ + apiKey: 'fc-piped-key', + apiUrl: 'https://api.firecrawl.dev', + }); + }); + }); }); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2ff3d32f8e..bc84437b85 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -24,8 +24,12 @@ const MAX_TRANSPORT_FAILURES = 3; // The server rate limits at 30 polls per minute, so allow it to recover. const MAX_SERVER_FAILURES = 5; +const NO_LOGIN_INPUT_MESSAGE = + 'No input to log in with. Run "firecrawl login --browser", pass --api-key, or set FIRECRAWL_API_KEY.'; + /** - * Prompt for input + * Prompt for input. Rejects when stdin ends before an answer (CI, agents, + * ` { const rl = readline.createInterface({ @@ -33,8 +37,13 @@ function promptInput(question: string): Promise { output: process.stdout, }); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + let answered = false; + rl.on('close', () => { + if (!answered) reject(new Error(NO_LOGIN_INPUT_MESSAGE)); + }); rl.question(question, (answer: string) => { + answered = true; rl.close(); resolve(answer.trim()); }); From cabc95e47e3741bc297d7f83b5961d983387860a Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:54:21 +0000 Subject: [PATCH 2/6] fix(auth): keep an unterminated piped answer and exit on Ctrl+C at the login prompt --- src/__tests__/utils/auth.test.ts | 10 ++++++++++ src/utils/auth.ts | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index 2452fd6f07..829ef2f305 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -143,6 +143,16 @@ describe('Authentication Utilities', () => { await expect(login).rejects.toThrow(/No input to log in with/); }); + it('reads a piped API key without a trailing newline', async () => { + const login = manualLogin(); + stdin.end('fc-unterminated-key'); + + await expect(login).resolves.toEqual({ + apiKey: 'fc-unterminated-key', + apiUrl: 'https://api.firecrawl.dev', + }); + }); + it('still reads a piped API key', async () => { const login = manualLogin(); stdin.end('fc-piped-key\n'); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index bc84437b85..905f3bb118 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -30,6 +30,7 @@ const NO_LOGIN_INPUT_MESSAGE = /** * Prompt for input. Rejects when stdin ends before an answer (CI, agents, * ` { const rl = readline.createInterface({ @@ -39,8 +40,22 @@ function promptInput(question: string): Promise { return new Promise((resolve, reject) => { let answered = false; + let unterminated = ''; + const onData = (chunk: Buffer | string): void => { + const text = unterminated + chunk.toString(); + unterminated = text.slice(text.search(/[^\r\n]*$/)); + }; + process.stdin.on('data', onData); + rl.on('SIGINT', () => { + rl.close(); + process.exit(130); + }); rl.on('close', () => { - if (!answered) reject(new Error(NO_LOGIN_INPUT_MESSAGE)); + process.stdin.off('data', onData); + if (answered) return; + const answer = unterminated.trim(); + if (answer) resolve(answer); + else reject(new Error(NO_LOGIN_INPUT_MESSAGE)); }); rl.question(question, (answer: string) => { answered = true; From 0f7e3365729e96a80c4d872496169c1135b89591 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:59:24 +0000 Subject: [PATCH 3/6] fix(auth): read the unterminated fallback only from piped stdin --- src/__tests__/utils/auth.test.ts | 9 +++++++++ src/utils/auth.ts | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index 829ef2f305..a04d21d3df 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -153,6 +153,15 @@ describe('Authentication Utilities', () => { }); }); + it('ignores a terminal buffer when the terminal ends the prompt', async () => { + Object.assign(stdin, { isTTY: true }); + const login = manualLogin(); + stdin.write('fc-erased'); + stdin.end(); + + await expect(login).rejects.toThrow(/No input to log in with/); + }); + it('still reads a piped API key', async () => { const login = manualLogin(); stdin.end('fc-piped-key\n'); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 905f3bb118..9b3c9dadc2 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -30,7 +30,8 @@ const NO_LOGIN_INPUT_MESSAGE = /** * Prompt for input. Rejects when stdin ends before an answer (CI, agents, * ` { const rl = readline.createInterface({ @@ -45,7 +46,7 @@ function promptInput(question: string): Promise { const text = unterminated + chunk.toString(); unterminated = text.slice(text.search(/[^\r\n]*$/)); }; - process.stdin.on('data', onData); + if (!process.stdin.isTTY) process.stdin.on('data', onData); rl.on('SIGINT', () => { rl.close(); process.exit(130); From e4f7269690e93bb4019fc6e1e026d2b941395211 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:11:26 +0000 Subject: [PATCH 4/6] fix(cli): no interactive auth without a terminal, shared prompt, skills before login in init -y --- src/__tests__/commands/init.test.ts | 37 +++++++++++++++----- src/commands/init.ts | 52 +++++++++++++-------------- src/commands/launch.ts | 20 +++-------- src/commands/setup.ts | 20 +++-------- src/index.ts | 2 +- src/utils/auth.ts | 54 ++++++----------------------- src/utils/prompt.ts | 44 +++++++++++++++++++++++ 7 files changed, 121 insertions(+), 108 deletions(-) create mode 100644 src/utils/prompt.ts diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index 5e28999d5d..90dfbf57d7 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -7,14 +7,19 @@ import { CLI_SKILLS, WORKFLOW_SKILLS } from '../../commands/skills-install'; const cliSkillFlags = `--skill ${CLI_SKILLS.join(' ')}`; const workflowSkillFlags = `--skill ${WORKFLOW_SKILLS.join(' ')}`; -const { installMcpMock, getApiKeyMock, confirmMock, checkboxMock } = vi.hoisted( - () => ({ - installMcpMock: vi.fn(), - getApiKeyMock: vi.fn(), - confirmMock: vi.fn(), - checkboxMock: vi.fn(), - }) -); +const { + installMcpMock, + getApiKeyMock, + confirmMock, + checkboxMock, + interactiveLoginMock, +} = vi.hoisted(() => ({ + installMcpMock: vi.fn(), + getApiKeyMock: vi.fn(), + confirmMock: vi.fn(), + checkboxMock: vi.fn(), + interactiveLoginMock: vi.fn(), +})); vi.mock('child_process', () => ({ execSync: vi.fn(), @@ -37,6 +42,11 @@ vi.mock('../../utils/config', async (importOriginal) => ({ getApiKey: getApiKeyMock, })); +vi.mock('../../utils/auth', async (importOriginal) => ({ + ...(await importOriginal()), + interactiveLogin: interactiveLoginMock, +})); + vi.mock('@inquirer/prompts', () => ({ confirm: confirmMock, checkbox: checkboxMock, @@ -80,6 +90,17 @@ describe('handleInitCommand', () => { ); }); + it('installs skills before a non-interactive login that can fail or wait', async () => { + interactiveLoginMock.mockRejectedValueOnce(new Error('No input')); + + await handleInitCommand({ yes: true, skipInstall: true }); + + const skillsOrder = vi.mocked(execSync).mock.invocationCallOrder[0]; + const loginOrder = interactiveLoginMock.mock.invocationCallOrder[0]; + expect(skillsOrder).toBeLessThan(loginOrder); + expect(process.exitCode).toBe(1); + }); + it('scopes non-interactive skills install to one agent when provided', async () => { await handleInitCommand({ yes: true, diff --git a/src/commands/init.ts b/src/commands/init.ts index 6fe9718135..9535382fcc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1042,9 +1042,9 @@ export async function handleInitCommand( async function runNonInteractive(options: InitOptions): Promise { const failures: string[] = []; const steps: string[] = []; - if (!options.skipAuth) steps.push('auth'); if (!options.skipInstall) steps.push('install'); if (!options.skipSkills) steps.push('skills'); + if (!options.skipAuth) steps.push('auth'); const total = steps.length; let current = 0; @@ -1053,6 +1053,31 @@ async function runNonInteractive(options: InitOptions): Promise { return `${bold}[${current}/${total}]${reset}`; }; + if (!options.skipInstall) { + console.log(`${stepLabel()} Installing firecrawl-cli globally...`); + if (!installGlobalCli()) failures.push('global CLI installation'); + } + + let skillCount: number | null = null; + let skillsInstalled = false; + if (!options.skipSkills) { + console.log( + `${stepLabel()} Installing firecrawl skills for AI coding agents...` + ); + for (const selection of [INIT_CLI_SELECTION, INIT_WORKFLOW_SELECTION]) { + try { + const count = await installSkillRepoQuiet(selection, options); + skillsInstalled = true; + if (count != null) skillCount = (skillCount ?? 0) + count; + } catch { + console.error( + `\n${dim}Failed to install ${selection.label}. Retry with: ${selection.retryCommand}${reset}` + ); + failures.push(selection.label); + } + } + } + if (!options.skipAuth) { if (options.apiKey) { console.log(`${stepLabel()} Authenticating with API key...`); @@ -1094,31 +1119,6 @@ async function runNonInteractive(options: InitOptions): Promise { } } - if (!options.skipInstall) { - console.log(`${stepLabel()} Installing firecrawl-cli globally...`); - if (!installGlobalCli()) failures.push('global CLI installation'); - } - - let skillCount: number | null = null; - let skillsInstalled = false; - if (!options.skipSkills) { - console.log( - `${stepLabel()} Installing firecrawl skills for AI coding agents...` - ); - for (const selection of [INIT_CLI_SELECTION, INIT_WORKFLOW_SELECTION]) { - try { - const count = await installSkillRepoQuiet(selection, options); - skillsInstalled = true; - if (count != null) skillCount = (skillCount ?? 0) + count; - } catch { - console.error( - `\n${dim}Failed to install ${selection.label}. Retry with: ${selection.retryCommand}${reset}` - ); - failures.push(selection.label); - } - } - } - printNextSteps( skillCount, false, diff --git a/src/commands/launch.ts b/src/commands/launch.ts index e0b1bcc00f..bdd5515d5f 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1,11 +1,11 @@ import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; -import readline from 'readline'; import { spawnSync } from 'child_process'; import { installMcp, installSkillsForAgent } from './setup'; import { ALL_SKILL_REPOS } from './skills-install'; import { getApiKey } from '../utils/config'; +import { promptInput } from '../utils/prompt'; export interface LaunchOptions { config?: boolean; @@ -121,19 +121,6 @@ function supportedTargets(): string { .join(', '); } -function promptInput(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer: string) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - async function pickLaunchTarget(): Promise { if (!process.stdin.isTTY) { throw new Error( @@ -147,7 +134,10 @@ async function pickLaunchTarget(): Promise { }); console.log(''); - const answer = await promptInput('Launch app: '); + const answer = await promptInput( + 'Launch app: ', + 'No launch app chosen. Pass the app name, for example "firecrawl launch claude".' + ); const byNumber = Number.parseInt(answer, 10); if ( Number.isInteger(byNumber) && diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f8d27bf439..e3b844502b 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -13,10 +13,10 @@ import { } from 'fs'; import os from 'os'; import path from 'path'; -import readline from 'readline'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getApiKey, updateConfig } from '../utils/config'; import { browserLogin, isAuthenticated } from '../utils/auth'; +import { promptInput } from '../utils/prompt'; import { saveCredentials } from '../utils/credentials'; import { buildSkillsInstallArgs, @@ -495,19 +495,6 @@ function resolveWebAgent(agent: string): WebAgent | null { return null; } -function promptInput(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer: string) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - /** * Interactively ask which harnesses to apply the change to, one by one. * Returns the selected agents, or null if the user aborted. @@ -526,7 +513,10 @@ async function pickWebAgents(undo: boolean): Promise { const selected: WebAgent[] = []; for (const agent of WEB_AGENTS) { const answer = ( - await promptInput(` ${verb} ${agent}? [Y/n] `) + await promptInput( + ` ${verb} ${agent}? [Y/n] `, + 'No answer received. Pass --agent or --yes to skip the harness prompts.' + ) ).toLowerCase(); if (answer === '' || answer === 'y' || answer === 'yes') { selected.push(agent); diff --git a/src/index.ts b/src/index.ts index 4db5d89928..fb11f8906d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2736,7 +2736,7 @@ async function main() { if (args.length === 0) { const { isAuthenticated } = await import('./utils/auth'); - if (!isAuthenticated()) { + if (!isAuthenticated() && process.stdin.isTTY) { // Not authenticated - run the onboarding auth step, which offers logging in // OR continuing on the keyless free tier (no API key required). printBanner(); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 9b3c9dadc2..42dfb2d05e 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -3,7 +3,6 @@ * Provides automatic authentication prompts when credentials are missing */ -import * as readline from 'readline'; import * as crypto from 'crypto'; import { loadCredentials, @@ -11,6 +10,7 @@ import { getConfigDirectoryPath, } from './credentials'; import { updateConfig, getApiKey } from './config'; +import { promptInput } from './prompt'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; // The apex redirects to www, and egress allowlists often permit only www. @@ -27,45 +27,6 @@ const MAX_SERVER_FAILURES = 5; const NO_LOGIN_INPUT_MESSAGE = 'No input to log in with. Run "firecrawl login --browser", pass --api-key, or set FIRECRAWL_API_KEY.'; -/** - * Prompt for input. Rejects when stdin ends before an answer (CI, agents, - * ` { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve, reject) => { - let answered = false; - let unterminated = ''; - const onData = (chunk: Buffer | string): void => { - const text = unterminated + chunk.toString(); - unterminated = text.slice(text.search(/[^\r\n]*$/)); - }; - if (!process.stdin.isTTY) process.stdin.on('data', onData); - rl.on('SIGINT', () => { - rl.close(); - process.exit(130); - }); - rl.on('close', () => { - process.stdin.off('data', onData); - if (answered) return; - const answer = unterminated.trim(); - if (answer) resolve(answer); - else reject(new Error(NO_LOGIN_INPUT_MESSAGE)); - }); - rl.question(question, (answer: string) => { - answered = true; - rl.close(); - resolve(answer.trim()); - }); - }); -} - /** * Open URL in the default browser */ @@ -693,7 +654,8 @@ async function manualLogin( if (isCustomUrl) { const apiKey = await promptInput( - 'Enter your API key (press Enter to skip): ' + 'Enter your API key (press Enter to skip): ', + NO_LOGIN_INPUT_MESSAGE ); return { apiKey: apiKey.trim(), @@ -701,7 +663,10 @@ async function manualLogin( }; } - const apiKey = await promptInput('Enter your Firecrawl API key: '); + const apiKey = await promptInput( + 'Enter your Firecrawl API key: ', + NO_LOGIN_INPUT_MESSAGE + ); if (!apiKey || apiKey.trim().length === 0) { throw new Error('API key cannot be empty'); @@ -790,7 +755,10 @@ async function interactiveLogin( printEnvHint(); printTelemetryNotice(); - const choice = await promptInput('Enter choice [1/2]: '); + const choice = await promptInput( + 'Enter choice [1/2]: ', + NO_LOGIN_INPUT_MESSAGE + ); if (choice === '2' || choice.toLowerCase() === 'manual') { return manualLogin(effectiveApiUrl); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000000..2c6cabc62d --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,44 @@ +import * as readline from 'readline'; + +/** + * Prompt for one line of input. Rejects with `noInputMessage` when stdin ends + * before an answer (CI, agents, ` { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve, reject) => { + let answered = false; + let unterminated = ''; + const onData = (chunk: Buffer | string): void => { + const text = unterminated + chunk.toString(); + unterminated = text.slice(text.search(/[^\r\n]*$/)); + }; + if (!process.stdin.isTTY) process.stdin.on('data', onData); + rl.on('SIGINT', () => { + rl.close(); + process.exit(130); + }); + rl.on('close', () => { + process.stdin.off('data', onData); + if (answered) return; + const answer = unterminated.trim(); + if (answer) resolve(answer); + else reject(new Error(noInputMessage)); + }); + rl.question(question, (answer: string) => { + answered = true; + rl.close(); + resolve(answer.trim()); + }); + }); +} From 5fefb0903da94f74dc0709bd1cd6fb11ac03266d Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:21:12 +0000 Subject: [PATCH 5/6] fix(auth): answer successive prompts from one piped stdin reader --- src/__tests__/utils/auth.test.ts | 17 +++++-- src/utils/auth.ts | 78 ++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/__tests__/utils/auth.test.ts b/src/__tests__/utils/auth.test.ts index a04d21d3df..1d8352bc4c 100644 --- a/src/__tests__/utils/auth.test.ts +++ b/src/__tests__/utils/auth.test.ts @@ -108,7 +108,7 @@ describe('Authentication Utilities', () => { }); describe('login prompts without a terminal', () => { - const realStdin = process.stdin; + const realStdin = Object.getOwnPropertyDescriptor(process, 'stdin')!; let stdin: PassThrough; beforeEach(() => { @@ -122,10 +122,7 @@ describe('Authentication Utilities', () => { }); afterEach(() => { - Object.defineProperty(process, 'stdin', { - value: realStdin, - configurable: true, - }); + Object.defineProperty(process, 'stdin', realStdin); vi.restoreAllMocks(); }); @@ -162,6 +159,16 @@ describe('Authentication Utilities', () => { await expect(login).rejects.toThrow(/No input to log in with/); }); + it('answers successive prompts from one pipe', async () => { + const login = interactiveLogin(); + stdin.end('2\nfc-second-line\n'); + + await expect(login).resolves.toEqual({ + apiKey: 'fc-second-line', + apiUrl: 'https://api.firecrawl.dev', + }); + }); + it('still reads a piped API key', async () => { const login = manualLogin(); stdin.end('fc-piped-key\n'); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 9b3c9dadc2..ff3821db48 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -27,13 +27,71 @@ const MAX_SERVER_FAILURES = 5; const NO_LOGIN_INPUT_MESSAGE = 'No input to log in with. Run "firecrawl login --browser", pass --api-key, or set FIRECRAWL_API_KEY.'; +type PipedInput = { + input: NodeJS.ReadableStream; + rl: readline.Interface; + lines: string[]; + waiters: Array<(line: string | null) => void>; + closed: boolean; +}; + +let piped: PipedInput | undefined; + +/** + * Next line of non-terminal stdin. One reader serves every prompt, so a pipe + * that answers several prompts (`printf '2\nfc-...\n'`) keeps its later lines. + * Resolves null once stdin has ended. + */ +function nextPipedLine(): Promise { + if (!piped || piped.input !== process.stdin) { + const state: PipedInput = { + input: process.stdin, + rl: readline.createInterface({ input: process.stdin }), + lines: [], + waiters: [], + closed: false, + }; + state.rl.on('line', (line) => { + const waiter = state.waiters.shift(); + if (waiter) waiter(line); + else state.lines.push(line); + if (!state.waiters.length) { + state.rl.pause(); + (state.input as { unref?: () => void }).unref?.(); + } + }); + state.rl.on('close', () => { + state.closed = true; + for (const waiter of state.waiters.splice(0)) waiter(null); + }); + piped = state; + } + const state = piped; + const buffered = state.lines.shift(); + if (buffered !== undefined) return Promise.resolve(buffered); + if (state.closed) return Promise.resolve(null); + return new Promise((resolve) => { + state.waiters.push(resolve); + (state.input as { ref?: () => void }).ref?.(); + state.rl.resume(); + }); +} + /** - * Prompt for input. Rejects when stdin ends before an answer (CI, agents, - * ` { + const noInput = NO_LOGIN_INPUT_MESSAGE; + if (!process.stdin.isTTY) { + process.stdout.write(question); + return nextPipedLine().then((line) => { + if (line === null) throw new Error(noInput); + return line.trim(); + }); + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -41,22 +99,12 @@ function promptInput(question: string): Promise { return new Promise((resolve, reject) => { let answered = false; - let unterminated = ''; - const onData = (chunk: Buffer | string): void => { - const text = unterminated + chunk.toString(); - unterminated = text.slice(text.search(/[^\r\n]*$/)); - }; - if (!process.stdin.isTTY) process.stdin.on('data', onData); rl.on('SIGINT', () => { rl.close(); process.exit(130); }); rl.on('close', () => { - process.stdin.off('data', onData); - if (answered) return; - const answer = unterminated.trim(); - if (answer) resolve(answer); - else reject(new Error(NO_LOGIN_INPUT_MESSAGE)); + if (!answered) reject(new Error(noInput)); }); rl.question(question, (answer: string) => { answered = true; From a885b632021692bda4487a506b238141f6cc7ed5 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:48:22 +0000 Subject: [PATCH 6/6] fix(cli): point closed-input errors at commands that take the suggested flags --- src/commands/setup.ts | 2 +- src/utils/auth.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8593b6ca84..72d8713ed6 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -558,7 +558,7 @@ async function pickWebAgents(undo: boolean): Promise { const answer = ( await promptInput( ` ${verb} ${agent}? [Y/n] `, - 'No answer received. Pass --agent or --yes to skip the harness prompts.' + 'No answer received. Run "firecrawl setup defaults --agent " or "firecrawl setup defaults --yes" to skip the harness prompts.' ) ).toLowerCase(); if (answer === '' || answer === 'y' || answer === 'yes') { diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 42dfb2d05e..3eda793e16 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -655,7 +655,7 @@ async function manualLogin( if (isCustomUrl) { const apiKey = await promptInput( 'Enter your API key (press Enter to skip): ', - NO_LOGIN_INPUT_MESSAGE + 'No input to configure this API URL. Pass --api-key or set FIRECRAWL_API_KEY.' ); return { apiKey: apiKey.trim(),