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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.26.15",
"version": "1.26.16",
Comment thread
capy-ai[bot] marked this conversation as resolved.
"publishConfig": {
"tag": "latest"
},
Expand Down
38 changes: 30 additions & 8 deletions src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -59,6 +64,11 @@ vi.mock('../../utils/config', async (importOriginal) => ({
getApiKey: getApiKeyMock,
}));

vi.mock('../../utils/auth', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/auth')>()),
interactiveLogin: interactiveLoginMock,
}));

vi.mock('@inquirer/prompts', () => ({
confirm: confirmMock,
checkbox: checkboxMock,
Expand Down Expand Up @@ -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',
Expand Down
52 changes: 26 additions & 26 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,9 +954,9 @@ export async function handleInitCommand(
async function runNonInteractive(options: InitOptions): Promise<void> {
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;

Expand All @@ -965,6 +965,31 @@ async function runNonInteractive(options: InitOptions): Promise<void> {
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...`);
Expand Down Expand Up @@ -1006,31 +1031,6 @@ async function runNonInteractive(options: InitOptions): Promise<void> {
}
}

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,
Expand Down
20 changes: 5 additions & 15 deletions src/commands/launch.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -121,19 +121,6 @@ function supportedTargets(): string {
.join(', ');
}

function promptInput(question: string): Promise<string> {
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<LaunchTarget> {
if (!process.stdin.isTTY) {
throw new Error(
Expand All @@ -147,7 +134,10 @@ async function pickLaunchTarget(): Promise<LaunchTarget> {
});
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) &&
Expand Down
20 changes: 5 additions & 15 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -538,19 +538,6 @@ function resolveWebAgent(agent: string): WebAgent | null {
return null;
}

function promptInput(question: string): Promise<string> {
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.
Expand All @@ -569,7 +556,10 @@ async function pickWebAgents(undo: boolean): Promise<WebAgent[] | null> {
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 <name>" or "firecrawl setup defaults --yes" to skip the harness prompts.'
)
).toLowerCase();
if (answer === '' || answer === 'y' || answer === 'yes') {
selected.push(agent);
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2766,7 +2766,7 @@ async function main() {
if (args.length === 0) {
const { isAuthenticated } = await import('./utils/auth');

if (!isAuthenticated()) {
if (!isAuthenticated() && process.stdin.isTTY) {
Comment thread
capy-ai[bot] marked this conversation as resolved.
// Not authenticated - run the onboarding auth step, which offers logging in
// OR continuing on the keyless free tier (no API key required).
printBanner();
Expand All @@ -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;
Expand Down
102 changes: 11 additions & 91 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
* Provides automatic authentication prompts when credentials are missing
*/

import * as readline from 'readline';
import * as crypto from 'crypto';
import {
loadCredentials,
saveCredentials,
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.
Expand All @@ -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<string | null> {
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, `</dev/null`, Ctrl+D), so callers fail instead of the process
* exiting 0 mid-prompt. Ctrl+C exits 130.
*/
function promptInput(question: string): Promise<string> {
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
*/
Expand Down Expand Up @@ -741,15 +654,19 @@ 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(),
apiUrl,
};
}

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');
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading