diff --git a/src/__tests__/utils/config.test.ts b/src/__tests__/utils/config.test.ts new file mode 100644 index 000000000..0069cbba2 --- /dev/null +++ b/src/__tests__/utils/config.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for config fallback priority + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + initializeConfig, + getConfig, + resetConfig, + updateConfig, +} from '../../utils/config'; +import { getClient, resetClient } from '../../utils/client'; +import * as credentials from '../../utils/credentials'; + +// Mock credentials module +vi.mock('../../utils/credentials', () => ({ + loadCredentials: vi.fn(), + saveCredentials: vi.fn(), +})); + +describe('Config Fallback Priority', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset everything before each test + resetClient(); + resetConfig(); + vi.clearAllMocks(); + + // Clear env vars + delete process.env.FIRECRAWL_API_KEY; + delete process.env.FIRECRAWL_API_URL; + + // Mock loadCredentials to return null by default + vi.mocked(credentials.loadCredentials).mockReturnValue(null); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + }); + + describe('initializeConfig fallback priority', () => { + it('should prioritize provided config over env vars', () => { + process.env.FIRECRAWL_API_KEY = 'env-api-key'; + process.env.FIRECRAWL_API_URL = 'https://env-api-url.com'; + + initializeConfig({ + apiKey: 'provided-api-key', + apiUrl: 'https://provided-api-url.com', + }); + + const config = getConfig(); + expect(config.apiKey).toBe('provided-api-key'); + expect(config.apiUrl).toBe('https://provided-api-url.com'); + }); + + it('should use env vars when provided config is not set', () => { + process.env.FIRECRAWL_API_KEY = 'env-api-key'; + process.env.FIRECRAWL_API_URL = 'https://env-api-url.com'; + + initializeConfig({}); + + const config = getConfig(); + expect(config.apiKey).toBe('env-api-key'); + expect(config.apiUrl).toBe('https://env-api-url.com'); + }); + + it('should fallback to stored credentials when env vars are not set', () => { + vi.mocked(credentials.loadCredentials).mockReturnValue({ + apiKey: 'stored-api-key', + apiUrl: 'https://stored-api-url.com', + }); + + initializeConfig({}); + + const config = getConfig(); + expect(config.apiKey).toBe('stored-api-key'); + expect(config.apiUrl).toBe('https://stored-api-url.com'); + }); + + it('should prioritize provided config > env vars > stored credentials', () => { + process.env.FIRECRAWL_API_KEY = 'env-api-key'; + vi.mocked(credentials.loadCredentials).mockReturnValue({ + apiKey: 'stored-api-key', + }); + + // Provided config should win + initializeConfig({ apiKey: 'provided-api-key' }); + expect(getConfig().apiKey).toBe('provided-api-key'); + + // Reset and test env var priority + resetConfig(); + initializeConfig({}); + expect(getConfig().apiKey).toBe('env-api-key'); + + // Reset and test stored credentials fallback + resetConfig(); + delete process.env.FIRECRAWL_API_KEY; + initializeConfig({}); + expect(getConfig().apiKey).toBe('stored-api-key'); + }); + }); + + describe('getClient fallback priority', () => { + beforeEach(() => { + // Set up base config + initializeConfig({ + apiKey: 'global-api-key', + apiUrl: 'https://global-url.com', + }); + }); + + it('should prioritize options over global config', () => { + const client = getClient({ apiKey: 'option-api-key' }); + + // Verify client was created with option API key + // We can't directly inspect the client, but we can check the config was updated + const config = getConfig(); + expect(config.apiKey).toBe('option-api-key'); + }); + + it('should use global config when options not provided', () => { + getClient(); + + const config = getConfig(); + expect(config.apiKey).toBe('global-api-key'); + expect(config.apiUrl).toBe('https://global-url.com'); + }); + + it('should merge options with global config', () => { + initializeConfig({ + apiKey: 'global-api-key', + apiUrl: 'https://global-url.com', + timeoutMs: 30000, + }); + + getClient({ apiKey: 'option-api-key' }); + + const config = getConfig(); + expect(config.apiKey).toBe('option-api-key'); // Option overrides + expect(config.apiUrl).toBe('https://global-url.com'); // Global preserved + expect(config.timeoutMs).toBe(30000); // Global preserved + }); + + it('should handle undefined options gracefully', () => { + initializeConfig({ apiKey: 'global-api-key' }); + + getClient({ apiKey: undefined }); + + // When undefined is passed, it should not override + const config = getConfig(); + expect(config.apiKey).toBe('global-api-key'); + }); + }); + + describe('Combined fallback chain', () => { + it('should follow: options > global config > env vars > stored credentials', () => { + // Set up stored credentials + vi.mocked(credentials.loadCredentials).mockReturnValue({ + apiKey: 'stored-api-key', + }); + + // Set up env vars + process.env.FIRECRAWL_API_KEY = 'env-api-key'; + + // Initialize with env vars (should use env > stored) + initializeConfig({}); + expect(getConfig().apiKey).toBe('env-api-key'); + + // Options should override everything + getClient({ apiKey: 'option-api-key' }); + expect(getConfig().apiKey).toBe('option-api-key'); + + // After reset, should fall back to env + resetConfig(); + initializeConfig({}); + expect(getConfig().apiKey).toBe('env-api-key'); + + // After clearing env, should fall back to stored + resetConfig(); + delete process.env.FIRECRAWL_API_KEY; + initializeConfig({}); + expect(getConfig().apiKey).toBe('stored-api-key'); + }); + + it('should update global config when getClient is called with options', () => { + process.env.FIRECRAWL_API_KEY = 'env-api-key'; + initializeConfig({}); + + // Initially should use env var + expect(getConfig().apiKey).toBe('env-api-key'); + + // Call getClient with option + getClient({ apiKey: 'option-api-key' }); + + // Global config should now be updated + expect(getConfig().apiKey).toBe('option-api-key'); + + // Subsequent getClient calls without options should use updated global config + resetClient(); // Reset client instance + getClient(); + expect(getConfig().apiKey).toBe('option-api-key'); + }); + }); + + describe('updateConfig behavior', () => { + it('should merge with existing config', () => { + initializeConfig({ + apiKey: 'initial-key', + apiUrl: 'https://initial-url.com', + }); + + updateConfig({ apiKey: 'updated-key' }); + + const config = getConfig(); + expect(config.apiKey).toBe('updated-key'); + expect(config.apiUrl).toBe('https://initial-url.com'); // Should be preserved + }); + + it('should allow partial updates', () => { + initializeConfig({ + apiKey: 'key1', + apiUrl: 'https://url1.com', + }); + + updateConfig({ apiUrl: 'https://url2.com' }); + + const config = getConfig(); + expect(config.apiKey).toBe('key1'); // Should be preserved + expect(config.apiUrl).toBe('https://url2.com'); // Should be updated + }); + }); +}); diff --git a/src/commands/crawl.ts b/src/commands/crawl.ts index c75d6c1f8..9d6a5c370 100644 --- a/src/commands/crawl.ts +++ b/src/commands/crawl.ts @@ -8,7 +8,6 @@ import type { CrawlStatusResult, } from '../types/crawl'; import { getClient } from '../utils/client'; -import { updateConfig } from '../utils/config'; import { isJobId } from '../utils/job'; import { writeOutput } from '../utils/output'; @@ -20,7 +19,7 @@ async function checkCrawlStatus( options: CrawlOptions ): Promise { try { - const app = getClient(); + const app = getClient({ apiKey: options.apiKey }); const status = await app.getCrawlStatus(jobId); return { @@ -49,12 +48,7 @@ export async function executeCrawl( options: CrawlOptions ): Promise { try { - // Update global config if API key is provided - if (options.apiKey) { - updateConfig({ apiKey: options.apiKey }); - } - - const app = getClient(); + const app = getClient({ apiKey: options.apiKey }); const { urlOrJobId, status, wait, pollInterval, timeout } = options; // If status flag is set or input looks like a job ID, check status diff --git a/src/commands/credit-usage.ts b/src/commands/credit-usage.ts index b0200a253..e4f04de85 100644 --- a/src/commands/credit-usage.ts +++ b/src/commands/credit-usage.ts @@ -4,7 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getConfig, updateConfig, validateConfig } from '../utils/config'; +import { getConfig, validateConfig } from '../utils/config'; +import { getClient } from '../utils/client'; export interface CreditUsageResult { success: boolean; @@ -35,17 +36,17 @@ export async function executeCreditUsage( options: CreditUsageOptions = {} ): Promise { try { - // Update global config if API key is provided via options + // Update config if API key provided (via getClient) if (options.apiKey) { - updateConfig({ apiKey: options.apiKey }); + getClient({ apiKey: options.apiKey }); } // Get config and validate API key const config = getConfig(); - validateConfig(config.apiKey); + const apiKey = options.apiKey || config.apiKey; + validateConfig(apiKey); const apiUrl = config.apiUrl || 'https://api.firecrawl.dev'; - const apiKey = config.apiKey!; // Make the API call to /v2/team/credit-usage const url = `${apiUrl.replace(/\/$/, '')}/v2/team/credit-usage`; diff --git a/src/commands/map.ts b/src/commands/map.ts index e1d80704c..6841dc034 100644 --- a/src/commands/map.ts +++ b/src/commands/map.ts @@ -4,7 +4,6 @@ import type { MapOptions, MapResult } from '../types/map'; import { getClient } from '../utils/client'; -import { updateConfig } from '../utils/config'; import { writeOutput } from '../utils/output'; /** @@ -12,12 +11,7 @@ import { writeOutput } from '../utils/output'; */ export async function executeMap(options: MapOptions): Promise { try { - // Update global config if API key is provided - if (options.apiKey) { - updateConfig({ apiKey: options.apiKey }); - } - - const app = getClient(); + const app = getClient({ apiKey: options.apiKey }); const { urlOrJobId } = options; // Build map options diff --git a/src/commands/scrape.ts b/src/commands/scrape.ts index 020faad20..5bad3239f 100644 --- a/src/commands/scrape.ts +++ b/src/commands/scrape.ts @@ -5,7 +5,6 @@ import type { FormatOption } from '@mendable/firecrawl-js'; import type { ScrapeOptions, ScrapeResult } from '../types/scrape'; import { getClient } from '../utils/client'; -import { updateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; /** @@ -15,13 +14,8 @@ export async function executeScrape( options: ScrapeOptions ): Promise { try { - // Update global config if API key is provided via options - if (options.apiKey) { - updateConfig({ apiKey: options.apiKey }); - } - - // Get client instance (uses global config) - const app = getClient(); + // Get client instance (updates global config if apiKey provided) + const app = getClient({ apiKey: options.apiKey }); // Build scrape options const formats: FormatOption[] = []; diff --git a/src/index.ts b/src/index.ts index 0d1b4e292..ba92997c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { Command } from 'commander'; import { handleScrapeCommand } from './commands/scrape'; -import { initializeConfig, updateConfig } from './utils/config'; +import { initializeConfig } from './utils/config'; +import { getClient } from './utils/client'; import { configure } from './commands/config'; import { handleCreditUsageCommand } from './commands/credit-usage'; import { handleCrawlCommand } from './commands/crawl'; @@ -36,7 +37,7 @@ program // Update global config if API key is provided via global option const globalOptions = thisCommand.opts(); if (globalOptions.apiKey) { - updateConfig({ apiKey: globalOptions.apiKey }); + getClient({ apiKey: globalOptions.apiKey }); } }); diff --git a/src/utils/client.ts b/src/utils/client.ts index 51da23149..317e093ef 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -5,7 +5,12 @@ import Firecrawl from '@mendable/firecrawl-js'; import type { FirecrawlClientOptions } from '@mendable/firecrawl-js'; -import { getConfig, validateConfig, type GlobalConfig } from './config'; +import { + getConfig, + validateConfig, + updateConfig, + type GlobalConfig, +} from './config'; let clientInstance: Firecrawl | null = null; @@ -22,8 +27,31 @@ export function getClient( ): string | undefined => value === null || value === undefined ? undefined : value; - // If options provided, create a new instance (useful for command-specific overrides) + // If options provided, update global config and create a new instance if (options) { + // Update global config with provided options (for future calls) + // Only include properties that are explicitly provided (not undefined) + const configUpdate: Partial = {}; + if (options.apiKey !== undefined) { + configUpdate.apiKey = normalizeApiKey(options.apiKey); + } + if (options.apiUrl !== undefined) { + configUpdate.apiUrl = normalizeApiKey(options.apiUrl); + } + if (options.timeoutMs !== undefined) { + configUpdate.timeoutMs = options.timeoutMs; + } + if (options.maxRetries !== undefined) { + configUpdate.maxRetries = options.maxRetries; + } + if (options.backoffFactor !== undefined) { + configUpdate.backoffFactor = options.backoffFactor; + } + + if (Object.keys(configUpdate).length > 0) { + updateConfig(configUpdate); + } + const config = getConfig(); const apiKey = normalizeApiKey(options.apiKey) ?? config.apiKey; const apiUrl = normalizeApiKey(options.apiUrl) ?? config.apiUrl;