diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index 6cf43728ef..8bd9dbbbf2 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execSync } from 'child_process'; +import * as skillsNative from '../../commands/skills-native'; import { handleInitCommand } from '../../commands/init'; import { CLI_SKILLS, WORKFLOW_SKILLS } from '../../commands/skills-install'; @@ -19,6 +20,14 @@ vi.mock('child_process', () => ({ execSync: vi.fn(), })); +vi.mock('../../commands/skills-native', () => ({ + detectInstalledAgentNames: vi.fn(() => []), + hasNpx: vi.fn(() => true), + installSkillsNative: vi.fn(async () => { + throw new Error('Native install unavailable in this test'); + }), +})); + vi.mock('../../commands/setup', () => ({ installMcp: installMcpMock, })); @@ -37,6 +46,7 @@ vi.mock('@inquirer/prompts', () => ({ describe('handleInitCommand', () => { beforeEach(() => { vi.clearAllMocks(); + process.exitCode = 0; getApiKeyMock.mockReturnValue(undefined); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -44,6 +54,8 @@ describe('handleInitCommand', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = 0; }); it('installs CLI and workflow skills from the catalog globally across all detected agents in non-interactive mode', async () => { @@ -86,6 +98,39 @@ describe('handleInitCommand', () => { ); }); + it('summarizes interactive skills success even when npx output has no count', async () => { + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + checkboxMock.mockResolvedValueOnce(['skills']); + vi.mocked(execSync).mockReturnValueOnce(Buffer.from('Done')); + await handleInitCommand({ skipInstall: true, skipAuth: true }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Skills installed') + ); + }); + + it('summarizes native interactive installation independently of its count', async () => { + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + checkboxMock.mockResolvedValueOnce(['skills']); + vi.mocked(skillsNative.installSkillsNative).mockResolvedValueOnce({ + skillCount: 14, + linkedAgents: ['cursor'], + } as Awaited>); + await handleInitCommand({ + skipInstall: true, + skipAuth: true, + agent: 'cursor', + }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('14 skills') + ); + }); + it('routes interactive MCP setup through the hardened hosted installer', async () => { getApiKeyMock.mockReturnValue('fc-stored-key'); confirmMock @@ -136,3 +181,123 @@ describe('handleInitCommand', () => { ); }); }); + +describe('init global installation', () => { + beforeEach(() => { + vi.mocked(execSync).mockReset(); + process.exitCode = 0; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubEnv('npm_execpath', ''); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = 0; + }); + + it.each([ + ['npm', '10.9.0', 'npm install -g firecrawl-cli'], + ['pnpm', '10.12.1', 'pnpm add -g firecrawl-cli'], + ['bun', '1.3.0', 'bun add -g firecrawl-cli'], + ])('uses %s for global installation', async (manager, version, command) => { + vi.stubEnv('npm_config_user_agent', `${manager}/${version}`); + vi.mocked(execSync).mockReturnValue(Buffer.from(version)); + await handleInitCommand({ all: true, skipAuth: true, skipSkills: true }); + expect(execSync).toHaveBeenCalledWith( + command, + expect.objectContaining({ stdio: 'inherit' }) + ); + expect(process.exitCode).toBe(0); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Skills installed') + ); + }); + + it('preserves npm configuration during preflight, install, and bin discovery', async () => { + vi.stubEnv('npm_config_user_agent', 'npm/10.9.0'); + vi.stubEnv('npm_config_prefix', '/tmp/custom-prefix'); + vi.stubEnv('npm_config_registry', 'https://registry.example.com'); + vi.stubEnv('npm_config__auth', 'test-only-auth'); + vi.mocked(execSync).mockImplementation((command) => { + if ( + String(command).includes('firecrawl') && + !String(command).includes('install') + ) + throw new Error('not on PATH'); + return Buffer.from( + command === 'npm --version' ? '10.9.0' : '/tmp/custom-prefix' + ); + }); + await handleInitCommand({ all: true, skipAuth: true, skipSkills: true }); + for (const command of [ + 'npm --version', + 'npm install -g firecrawl-cli', + 'npm prefix -g', + ]) { + expect(execSync).toHaveBeenCalledWith( + command, + expect.objectContaining({ + env: expect.objectContaining({ + npm_config_prefix: '/tmp/custom-prefix', + npm_config_registry: 'https://registry.example.com', + npm_config__auth: 'test-only-auth', + }), + }) + ); + } + }); + + it('rejects obsolete npm before installation and exits unsuccessfully', async () => { + vi.stubEnv('npm_config_user_agent', 'npm/2.15.12'); + vi.mocked(execSync).mockReturnValue(Buffer.from('2.15.12')); + await handleInitCommand({ all: true, skipAuth: true, skipSkills: true }); + expect(execSync).not.toHaveBeenCalledWith( + 'npm install -g firecrawl-cli', + expect.anything() + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Unsupported npm version 2.15.12') + ); + expect(process.exitCode).toBe(1); + }); + + it.each([ + ['npm', 'npx firecrawl-cli'], + ['pnpm', 'pnpm dlx firecrawl-cli'], + ['bun', 'bunx firecrawl-cli'], + ])( + 'reports a failed %s install and preserves successful skills', + async (manager, runner) => { + vi.stubEnv('npm_config_user_agent', `${manager}/10.0.0`); + vi.mocked(execSync).mockImplementation((command) => { + if (String(command).includes(' -g firecrawl-cli')) + throw new Error('install failed'); + return Buffer.from( + String(command).endsWith('--version') + ? '10.0.0' + : 'Installed 14 skills' + ); + }); + await handleInitCommand({ all: true, skipAuth: true }); + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`${runner} `) + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('28 skills') + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Setup incomplete: global CLI installation failed' + ) + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('sudo') + ); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('running via npx') + ); + } + ); +}); diff --git a/src/__tests__/utils/package-manager.test.ts b/src/__tests__/utils/package-manager.test.ts new file mode 100644 index 0000000000..a1587dda76 --- /dev/null +++ b/src/__tests__/utils/package-manager.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { detectPackageManager } from '../../utils/package-manager'; + +describe('package manager detection', () => { + it.each([ + [{ npm_config_user_agent: 'npm/10.9.0 node/v22.0.0' }, 'npm'], + [{ npm_config_user_agent: 'pnpm/10.12.1 npm/? node/v22.0.0' }, 'pnpm'], + [{ npm_config_user_agent: 'bun/1.3.0' }, 'bun'], + [{ npm_execpath: '/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs' }, 'pnpm'], + [{ npm_execpath: '/home/user/.bun/bin/bun' }, 'bun'], + [{}, 'npm'], + ] as const)('detects %j as %s', (env, expected) => { + expect(detectPackageManager(env, ['node', '/app/firecrawl'], {})).toBe( + expected + ); + }); + + it.each([ + [ + '/private/tmp/bunx-501-firecrawl-cli@latest/node_modules/firecrawl-cli/dist/index.js', + 'bun', + ], + [ + '/home/user/.cache/pnpm/dlx/123/node_modules/firecrawl-cli/dist/index.js', + 'pnpm', + ], + [ + '/home/user/.local/share/pnpm/global/5/node_modules/firecrawl-cli/dist/index.js', + 'pnpm', + ], + [ + 'C:\\Users\\user\\AppData\\Local\\pnpm\\global\\5\\node_modules\\firecrawl-cli\\dist\\index.js', + 'pnpm', + ], + ])( + 'prefers install or runner path %s over inherited npm metadata', + (entry, expected) => { + expect( + detectPackageManager({ npm_config_user_agent: 'npm/2.15.12' }, [ + 'node', + entry, + ]) + ).toBe(expected); + } + ); + + it('detects a direct Bun invocation without package manager metadata', () => { + expect( + detectPackageManager({}, ['bun', '/app/dist/index.js'], { bun: '1.3.0' }) + ).toBe('bun'); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 4cf219df2a..6fe9718135 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,6 +5,11 @@ */ import { execSync } from 'child_process'; +import { + detectPackageManager, + PACKAGE_MANAGERS, + type PackageManager, +} from '../utils/package-manager'; import { isAuthenticated, browserLogin, interactiveLogin } from '../utils/auth'; import { saveCredentials } from '../utils/credentials'; import { updateConfig, getApiKey } from '../utils/config'; @@ -237,8 +242,7 @@ async function installSkillRepoQuiet( } } - // No npx and native already failed — nothing left to try - return null; + throw new Error('No skills installer is available.'); } /** @@ -298,7 +302,9 @@ function parseSkillCount(output: string): number | null { */ function printNextSteps( skillCount: number | null, - defaultsHandled = false + defaultsHandled = false, + skillsInstalled = true, + cliCommand = 'firecrawl' ): void { const arrow = `${dim}→${reset}`; const summary = @@ -307,34 +313,34 @@ function printNextSteps( : `${green}✓${reset} Skills installed ${dim}across your AI coding agents${reset}`; console.log(''); - console.log(` ${summary}`); + if (skillsInstalled) console.log(` ${summary}`); console.log(''); console.log( ` ${dim}Connect & interact with the web ${reset}${dim}(direct or in your AI agent):${reset}` ); console.log( - ` ${arrow} ${bold}Scrape${reset} "Scrape the pricing page of stripe.com" ${dim}firecrawl scrape https://stripe.com/pricing${reset}` + ` ${arrow} ${bold}Scrape${reset} "Scrape the pricing page of stripe.com" ${dim}${cliCommand} scrape https://stripe.com/pricing${reset}` ); console.log( - ` ${arrow} ${bold}Search${reset} "Search for the latest stories in AI" ${dim}firecrawl search "latest stories in AI"${reset}` + ` ${arrow} ${bold}Search${reset} "Search for the latest stories in AI" ${dim}${cliCommand} search "latest stories in AI"${reset}` ); console.log( - ` ${arrow} ${bold}Interact${reset} "Go to amazon.com, search keyboards, filter by Prime" ${dim}firecrawl interact "search keyboards, filter by Prime"${reset}` + ` ${arrow} ${bold}Interact${reset} "Go to amazon.com, search keyboards, filter by Prime" ${dim}${cliCommand} interact "search keyboards, filter by Prime"${reset}` ); console.log(''); console.log( - ` ${arrow} ${dim}Add MCP: ${reset} ${bold}firecrawl setup mcp${reset}` + ` ${arrow} ${dim}Add MCP: ${reset} ${bold}${cliCommand} setup mcp${reset}` ); if (!defaultsHandled) { console.log( - ` ${arrow} ${dim}Default web:${reset} ${bold}firecrawl setup defaults${reset}` + ` ${arrow} ${dim}Default web:${reset} ${bold}${cliCommand} setup defaults${reset}` ); } console.log( - ` ${arrow} ${dim}Make default:${reset} ${bold}firecrawl make default${reset}` + ` ${arrow} ${dim}Make default:${reset} ${bold}${cliCommand} make default${reset}` ); console.log( - ` ${arrow} ${dim}All commands:${reset} ${bold}firecrawl --help${reset}` + ` ${arrow} ${dim}All commands:${reset} ${bold}${cliCommand} --help${reset}` ); console.log(''); console.log( @@ -358,18 +364,20 @@ function cliIsOnPath(): boolean { } } -/** The directory npm drops global bins into, or null if it can't be resolved. */ -function globalBinDir(): string | null { +/** Resolve the selected package manager's global executable directory. */ +function globalBinDir(manager: PackageManager): string | null { try { - const prefix = execSync('npm prefix -g', { + const prefix = execSync(PACKAGE_MANAGERS[manager].bin, { stdio: ['ignore', 'pipe', 'pipe'], - env: cleanNpmEnv(), + env: process.env, }) .toString() .trim(); if (!prefix) return null; - // On Windows global bins live in the prefix root; elsewhere in /bin. - return process.platform === 'win32' ? prefix : `${prefix}/bin`; + // npm returns a prefix; pnpm and Bun return the executable directory. + return manager === 'npm' && process.platform !== 'win32' + ? `${prefix}/bin` + : prefix; } catch { return null; } @@ -393,10 +401,10 @@ function rcFileForShell(): string { * fresh Windows shell. The install reports success, yet the command silently * doesn't exist in any new terminal. Detect that and print the exact fix. */ -function warnIfCliNotOnPath(): void { +function warnIfCliNotOnPath(manager: PackageManager): void { if (cliIsOnPath()) return; - const bin = globalBinDir(); + const bin = globalBinDir(manager); console.log(''); console.log( ` ${bold}⚠ "firecrawl" was installed but isn't on your PATH yet.${reset}` @@ -417,11 +425,11 @@ function warnIfCliNotOnPath(): void { console.log(` ${bold}echo '${line}' >> ${rc}${reset}`); } else { console.log( - ` ${dim}Add your npm global bin directory (see "npm prefix -g") to PATH.${reset}` + ` ${dim}Add your global bin directory (see "${PACKAGE_MANAGERS[manager].bin}") to PATH.${reset}` ); } console.log( - ` ${dim}Until then you can still run:${reset} ${bold}npx firecrawl-cli ${reset}` + ` ${dim}Until then you can still run:${reset} ${bold}${PACKAGE_MANAGERS[manager].run} ${reset}` ); console.log(''); } @@ -436,14 +444,49 @@ async function stepInstall(): Promise { if (!shouldInstall) return true; console.log(`\n Installing firecrawl-cli globally...`); + return installGlobalCli(); +} + +function installGlobalCli(): boolean { + const manager = detectPackageManager(); + const commands = PACKAGE_MANAGERS[manager]; try { - execSync('npm install -g firecrawl-cli', { stdio: 'inherit' }); - console.log(` ${green}✓${reset} CLI installed globally\n`); - warnIfCliNotOnPath(); + const version = execSync(`${manager} --version`, { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + timeout: 10_000, + }) + .toString() + .trim(); + console.log( + ` Using ${manager} ${version} from PATH (Node ${process.version})` + ); + if ( + !/^\d+\.\d+\.\d+/.test(version) || + (manager === 'npm' && Number(version.split('.')[0]) < 7) + ) { + throw new Error( + `Unsupported ${manager} version ${version}. Update ${manager} and check your PATH before retrying.` + ); + } + execSync(commands.install, { stdio: 'inherit', env: process.env }); + console.log(`${green}✓${reset} CLI installed globally\n`); + warnIfCliNotOnPath(manager); return true; - } catch { + } catch (error) { + console.error(`Failed to install firecrawl-cli globally using ${manager}.`); console.error( - '\n Failed to install globally. You may need sudo or fix npm permissions.' + error instanceof Error ? error.message : 'Unknown install error' + ); + console.error( + `Check "${manager} --version" and your PATH. Retry: ${commands.install}` + ); + if (manager === 'pnpm') + console.error( + 'If the global bin directory is missing, run "pnpm setup" and restart your shell.' + ); + console.log( + `You can run without a global install: ${commands.run} ` ); return false; } @@ -519,7 +562,9 @@ export async function stepAuth(options: InitOptions): Promise { } } -async function stepIntegrations(options: InitOptions): Promise { +async function stepIntegrations( + options: InitOptions +): Promise<{ skillCount: number | null; skillsInstalled: boolean }> { const { checkbox, confirm } = await import('@inquirer/prompts'); const wantIntegrations = await confirm({ @@ -527,7 +572,7 @@ async function stepIntegrations(options: InitOptions): Promise { default: true, }); - if (!wantIntegrations) return null; + if (!wantIntegrations) return { skillCount: null, skillsInstalled: false }; const integrations = await checkbox({ message: 'Which integrations?', @@ -555,7 +600,7 @@ async function stepIntegrations(options: InitOptions): Promise { if (integrations.length === 0) { console.log(` ${dim}No integrations selected.${reset}\n`); - return null; + return { skillCount: null, skillsInstalled: false }; } // If skills/workflows are being installed, let the user route them to a @@ -571,6 +616,7 @@ async function stepIntegrations(options: InitOptions): Promise { : null; let totalSkills: number | null = null; + let skillsInstalled = false; for (const integration of integrations) { switch (integration) { case 'skills': { @@ -581,6 +627,7 @@ async function stepIntegrations(options: InitOptions): Promise { options, targetAgents ); + skillsInstalled = true; if (count != null) totalSkills = (totalSkills ?? 0) + count; } catch { console.error( @@ -610,6 +657,7 @@ async function stepIntegrations(options: InitOptions): Promise { options, targetAgents ); + skillsInstalled = true; if (count != null) totalSkills = (totalSkills ?? 0) + count; } catch { console.error( @@ -660,7 +708,7 @@ async function stepIntegrations(options: InitOptions): Promise { } } } - return totalSkills; + return { skillCount: totalSkills, skillsInstalled }; } /** @@ -949,10 +997,12 @@ export async function handleInitCommand( return; } + let installFailed = false; // Step 1: Install if (!options.skipInstall) { const ok = await stepInstall(); if (!ok) { + installFailed = true; console.log(` ${dim}Continuing with setup...${reset}\n`); } } @@ -964,8 +1014,9 @@ export async function handleInitCommand( // Step 3: Integrations (skills, MCP, env) let skillCount: number | null = null; + let skillsInstalled = false; if (!options.skipSkills) { - skillCount = await stepIntegrations(options); + ({ skillCount, skillsInstalled } = await stepIntegrations(options)); } // Step 4: Template @@ -974,10 +1025,22 @@ export async function handleInitCommand( // Step 5: Default web provider await stepDefaults(); - printNextSteps(skillCount, true); + printNextSteps( + skillCount, + true, + skillsInstalled, + installFailed ? PACKAGE_MANAGERS[detectPackageManager()].run : 'firecrawl' + ); + if (installFailed) { + console.error( + 'Setup incomplete: global CLI installation failed. Other completed steps are preserved.' + ); + process.exitCode = 1; + } } async function runNonInteractive(options: InitOptions): Promise { + const failures: string[] = []; const steps: string[] = []; if (!options.skipAuth) steps.push('auth'); if (!options.skipInstall) steps.push('install'); @@ -1025,6 +1088,7 @@ async function runNonInteractive(options: InitOptions): Promise { '\nAuthentication failed:', error instanceof Error ? error.message : 'Unknown error' ); + failures.push('authentication'); console.log('You can authenticate later with: firecrawl login\n'); } } @@ -1032,21 +1096,11 @@ async function runNonInteractive(options: InitOptions): Promise { if (!options.skipInstall) { console.log(`${stepLabel()} Installing firecrawl-cli globally...`); - try { - execSync('npm install -g firecrawl-cli', { stdio: 'inherit' }); - console.log(`${green}✓${reset} CLI installed globally\n`); - warnIfCliNotOnPath(); - } catch { - console.error( - `\n${dim}Failed to install firecrawl-cli globally. You may need sudo or fix npm permissions.${reset}` - ); - console.log( - `${dim}Continuing — the CLI is already running via npx.${reset}\n` - ); - } + 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...` @@ -1054,15 +1108,29 @@ async function runNonInteractive(options: InitOptions): Promise { 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}` ); - process.exit(1); + failures.push(selection.label); } } } - printNextSteps(skillCount); + printNextSteps( + skillCount, + false, + skillsInstalled, + failures.includes('global CLI installation') + ? PACKAGE_MANAGERS[detectPackageManager()].run + : 'firecrawl' + ); + if (failures.length > 0) { + console.error( + `Setup incomplete: ${failures.join(', ')} failed. Other completed steps are preserved.` + ); + process.exitCode = 1; + } } diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts new file mode 100644 index 0000000000..0da9e56190 --- /dev/null +++ b/src/utils/package-manager.ts @@ -0,0 +1,35 @@ +export type PackageManager = 'npm' | 'pnpm' | 'bun'; + +export function detectPackageManager( + env: NodeJS.ProcessEnv = process.env, + argv: string[] = process.argv, + versions: Partial = process.versions +): PackageManager { + // Install and runner paths identify the manager without lifecycle metadata. + const entry = (argv[1] ?? '').replaceAll('\\', '/'); + if (/bunx[-/]|\/\.bun\//.test(entry)) return 'bun'; + if (/\/pnpm\/(dlx|global)\//.test(entry)) return 'pnpm'; + const hint = `${env.npm_config_user_agent ?? ''} ${env.npm_execpath ?? ''}`; + if (/\bpnpm\b/i.test(hint)) return 'pnpm'; + if (/\bbun\b/i.test(hint)) return 'bun'; + if (versions.bun) return 'bun'; + return 'npm'; +} + +export const PACKAGE_MANAGERS = { + npm: { + install: 'npm install -g firecrawl-cli', + run: 'npx firecrawl-cli', + bin: 'npm prefix -g', + }, + pnpm: { + install: 'pnpm add -g firecrawl-cli', + run: 'pnpm dlx firecrawl-cli', + bin: 'pnpm bin -g', + }, + bun: { + install: 'bun add -g firecrawl-cli', + run: 'bunx firecrawl-cli', + bin: 'bun pm bin -g', + }, +} as const;