Skip to content
Open
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
108 changes: 108 additions & 0 deletions src/__tests__/commands/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests for status command — self-hosted recognition.
*
* Regression coverage for #53: a stored apiUrl pointing at a self-hosted
* Firecrawl instance with no apiKey must be reported as a configured
* "self-hosted" auth source, not "Not authenticated". The fetch helpers
* must also avoid sending `Authorization: Bearer undefined` in that case.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getStatus } from '../../commands/status';
import { initializeConfig, resetConfig } from '../../utils/config';
import * as credentials from '../../utils/credentials';

vi.mock('../../utils/credentials', () => ({
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
getConfigDirectoryPath: vi.fn().mockReturnValue('/mock/config/path'),
}));

const mockFetch = vi.fn();
global.fetch = mockFetch as any;

describe('getStatus self-hosted handling', () => {
const originalEnv = process.env;

beforeEach(() => {
resetConfig();
vi.clearAllMocks();
delete process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_API_URL;
});

afterEach(() => {
process.env = originalEnv;
});

it('reports authSource=self-hosted when apiUrl is custom and apiKey is missing', async () => {
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiUrl: 'http://localhost:3002',
});
initializeConfig({ apiUrl: 'http://localhost:3002' });

mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});

const status = await getStatus();

expect(status.authSource).toBe('self-hosted');
expect(status.authenticated).toBe(true);
});

it('does not send Authorization header when self-hosted has no apiKey', async () => {
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiUrl: 'http://localhost:3002',
});
initializeConfig({ apiUrl: 'http://localhost:3002' });

mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});

await getStatus();

expect(mockFetch).toHaveBeenCalled();
for (const call of mockFetch.mock.calls) {
const headers = (call[1]?.headers ?? {}) as Record<string, string>;
expect(headers).not.toHaveProperty('Authorization');
expect(JSON.stringify(headers)).not.toContain('Bearer undefined');
}
});

it('still reports stored when apiKey is present alongside apiUrl', async () => {
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiKey: 'fc-test-key',
apiUrl: 'http://localhost:3002',
});
initializeConfig({
apiKey: 'fc-test-key',
apiUrl: 'http://localhost:3002',
});

mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});

const status = await getStatus();

expect(status.authSource).toBe('stored');
expect(status.authenticated).toBe(true);
});

it('reports none when no apiKey and apiUrl is the default cloud URL', async () => {
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiUrl: 'https://api.firecrawl.dev',
});
initializeConfig({ apiUrl: 'https://api.firecrawl.dev' });

const status = await getStatus();

expect(status.authSource).toBe('none');
expect(status.authenticated).toBe(false);
});
});
76 changes: 52 additions & 24 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { promises as fs } from 'fs';
import path from 'path';
import packageJson from '../../package.json';
import { isAuthenticated } from '../utils/auth';
import { getConfig, validateConfig } from '../utils/config';
import { getConfig, isCustomApiUrl, validateConfig } from '../utils/config';
import { loadCredentials } from '../utils/credentials';
import { isSearchFeedbackDisabledLocally } from './search-feedback';

type AuthSource = 'env' | 'stored' | 'none';
type AuthSource = 'env' | 'stored' | 'self-hosted' | 'none';

interface QueueStatusResponse {
success: boolean;
Expand Down Expand Up @@ -55,7 +55,11 @@ interface LocalStatus {
}

/**
* Detect how the user is authenticated
* Detect how the user is authenticated.
*
* `self-hosted` is reported when the stored credentials point at a custom
* apiUrl and no apiKey is present. Self-hosted Firecrawl instances do not
* require an API key, so the CLI is considered configured in that state.
*/
function getAuthSource(): AuthSource {
if (process.env.FIRECRAWL_API_KEY) {
Expand All @@ -65,23 +69,38 @@ function getAuthSource(): AuthSource {
if (stored?.apiKey) {
return 'stored';
}
if (stored?.apiUrl && isCustomApiUrl(stored.apiUrl)) {
return 'self-hosted';
}
return 'none';
}

/**
* Build request headers for a status API call. Omits Authorization entirely
* when no apiKey is present (self-hosted with no key) so the server does not
* see `Authorization: Bearer undefined`.
*/
function buildStatusHeaders(apiKey?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}

/**
* Fetch queue status from API
*/
async function fetchQueueStatus(
apiKey: string,
apiKey: string | undefined,
apiUrl: string
): Promise<QueueStatusResponse> {
const url = `${apiUrl.replace(/\/$/, '')}/v2/team/queue-status`;
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
headers: buildStatusHeaders(apiKey),
});

if (!response.ok) {
Expand All @@ -98,16 +117,13 @@ async function fetchQueueStatus(
* Fetch credit usage from API
*/
async function fetchCreditUsage(
apiKey: string,
apiKey: string | undefined,
apiUrl: string
): Promise<CreditUsageResponse> {
const url = `${apiUrl.replace(/\/$/, '')}/v2/team/credit-usage`;
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
headers: buildStatusHeaders(apiKey),
});

if (!response.ok) {
Expand All @@ -125,9 +141,13 @@ async function fetchCreditUsage(
*/
export async function getStatus(): Promise<StatusResult> {
const authSource = getAuthSource();
// Self-hosted setups (custom apiUrl, no apiKey) are a valid configured state.
// Treat them as authenticated for reporting purposes so we don't tell users
// to run `firecrawl login` against an instance that has no login flow.
const authenticated = isAuthenticated() || authSource === 'self-hosted';
const result: StatusResult = {
version: packageJson.version,
authenticated: isAuthenticated(),
authenticated,
authSource,
};

Expand All @@ -142,10 +162,11 @@ export async function getStatus(): Promise<StatusResult> {

const apiUrl = config.apiUrl || 'https://api.firecrawl.dev';

// Fetch both endpoints in parallel
// Fetch both endpoints in parallel. apiKey may be undefined for
// self-hosted instances; the fetch helpers omit Authorization in that case.
const [queueStatus, creditUsage] = await Promise.all([
fetchQueueStatus(apiKey!, apiUrl),
fetchCreditUsage(apiKey!, apiUrl),
fetchQueueStatus(apiKey, apiUrl),
fetchCreditUsage(apiKey, apiUrl),
]);

if (queueStatus.success && queueStatus.maxConcurrency !== undefined) {
Expand Down Expand Up @@ -262,13 +283,20 @@ export async function handleStatusCommand(): Promise<void> {

// Auth status with source
if (status.authenticated) {
const sourceLabel =
status.authSource === 'env'
? 'via FIRECRAWL_API_KEY'
: 'via stored credentials';
console.log(
` ${green}●${reset} Authenticated ${dim}${sourceLabel}${reset}`
);
if (status.authSource === 'self-hosted') {
const apiUrl = getConfig().apiUrl;
console.log(
` ${green}●${reset} Self-hosted ${dim}${apiUrl || ''}${reset}`
);
} else {
const sourceLabel =
status.authSource === 'env'
? 'via FIRECRAWL_API_KEY'
: 'via stored credentials';
console.log(
` ${green}●${reset} Authenticated ${dim}${sourceLabel}${reset}`
);
}
} else {
console.log(` ${red}●${reset} Not authenticated`);
console.log(` ${dim}Run 'firecrawl login' to authenticate${reset}`);
Expand Down