diff --git a/package.json b/package.json index 4338745752..5e4844e079 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.15", + "version": "1.26.16", "publishConfig": { "tag": "latest" }, diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index 45f7996400..ee2495a1cf 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -25,14 +25,19 @@ vi.mock('../../utils/skill-catalog', () => ({ ]), })); -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(), @@ -59,6 +64,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, @@ -104,6 +114,18 @@ 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 skillOrders = vi.mocked(execSync).mock.invocationCallOrder; + const loginOrder = interactiveLoginMock.mock.invocationCallOrder[0]; + expect(skillOrders).toHaveLength(2); + expect(Math.max(...skillOrders)).toBeLessThan(loginOrder); + expect(process.exitCode).toBe(1); + }); + it('preserves detected targets when native installation falls back to npx', async () => { vi.mocked(skillsNative.detectInstalledAgentNames).mockReturnValue([ 'claude-code', diff --git a/src/commands/init.ts b/src/commands/init.ts index a67b666817..a5225e6e64 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -954,9 +954,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; @@ -965,6 +965,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...`); @@ -1006,31 +1031,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 13b13f4a84..72d8713ed6 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -16,10 +16,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 { getCatalogSkills } from '../utils/skill-catalog'; import { rememberSkillInstall } from '../utils/skill-update-notice'; @@ -538,19 +538,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. @@ -569,7 +556,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. Run "firecrawl setup defaults --agent " or "firecrawl setup defaults --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 080b05d978..59fd3957b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2766,7 +2766,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(); @@ -2778,7 +2778,7 @@ async function main() { return; } - // Authenticated - show banner and help + // Authenticated, or no terminal to prompt in - show banner and help printBanner(); program.outputHelp(); return; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index ff3821db48..3eda793e16 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,93 +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.'; -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 one line of 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, - }); - - return new Promise((resolve, reject) => { - let answered = false; - rl.on('SIGINT', () => { - rl.close(); - process.exit(130); - }); - rl.on('close', () => { - if (!answered) reject(new Error(noInput)); - }); - rl.question(question, (answer: string) => { - answered = true; - rl.close(); - resolve(answer.trim()); - }); - }); -} - /** * Open URL in the default browser */ @@ -741,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 input to configure this API URL. Pass --api-key or set FIRECRAWL_API_KEY.' ); return { apiKey: apiKey.trim(), @@ -749,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'); @@ -838,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..20380dc672 --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,90 @@ +import * as readline from 'readline'; + +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 one line of input. Rejects when stdin ends before an answer (CI, + * agents, ` { + if (!process.stdin.isTTY) { + process.stdout.write(question); + return nextPipedLine().then((line) => { + if (line === null) throw new Error(noInputMessage); + return line.trim(); + }); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve, reject) => { + let answered = false; + rl.on('SIGINT', () => { + rl.close(); + process.exit(130); + }); + rl.on('close', () => { + if (!answered) reject(new Error(noInputMessage)); + }); + rl.question(question, (answer: string) => { + answered = true; + rl.close(); + resolve(answer.trim()); + }); + }); +}