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
165 changes: 165 additions & 0 deletions src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
}));
Expand All @@ -37,13 +46,16 @@ 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(() => {});
});

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 () => {
Expand Down Expand Up @@ -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<ReturnType<typeof skillsNative.installSkillsNative>>);
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
Expand Down Expand Up @@ -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) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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} <command>`)
);
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')
);
}
);
});
52 changes: 52 additions & 0 deletions src/__tests__/utils/package-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
] 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');
});
});
Loading
Loading