From 2f47787744fb4556decd2dde5f4998c68cfc7531 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Tue, 29 Sep 2026 10:27:51 -0500 Subject: [PATCH] feat: tag keyless signup links from the CLI The API links keyless prompts to signup with utm_source=keyless&utm_medium=api. The CLI now rewrites utm_medium to cli in the errors it prints for keyless scrape, search, parse, interact, research and developer requests, so accounts created from the CLI are attributed to the CLI. --- src/__tests__/utils/client.test.ts | 66 ++++++++++++++++++++++++++++++ src/commands/interact.ts | 14 ++++--- src/commands/parse.ts | 7 ++-- src/utils/client.ts | 19 ++++++++- 4 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/utils/client.test.ts diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts new file mode 100644 index 0000000000..806a5119cc --- /dev/null +++ b/src/__tests__/utils/client.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for keyless request errors + * + * The API links every keyless prompt to signup tagged `utm_medium=api`. The CLI + * must retag that link as `cli` so signups started from the CLI are attributed + * to it. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + keylessGet, + keylessRequest, + withCliSignupTag, +} from '../../utils/client'; + +const API_LIMIT_MESSAGE = `You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=api + +Then authenticate with: +Authorization: Bearer YOUR_API_KEY`; + +const CLI_SIGNUP_URL = + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=cli'; + +function stubFetch(status: number, body: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status })) + ); +} + +describe('withCliSignupTag', () => { + it('retags the keyless signup link as cli', () => { + const message = withCliSignupTag(API_LIMIT_MESSAGE); + + expect(message).toContain(CLI_SIGNUP_URL); + expect(message).not.toContain('utm_medium=api'); + }); + + it('leaves messages without the keyless signup link unchanged', () => { + expect(withCliSignupTag('Firecrawl request failed (HTTP 500)')).toBe( + 'Firecrawl request failed (HTTP 500)' + ); + }); +}); + +describe('keyless requests', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports the keyless limit with the cli signup link', async () => { + stubFetch(429, { success: false, error: API_LIMIT_MESSAGE }); + + await expect( + keylessRequest('/v2/scrape', { url: 'https://example.com' }) + ).rejects.toThrow(CLI_SIGNUP_URL); + }); + + it('reports the keyless limit on GET requests with the cli signup link', async () => { + stubFetch(429, { success: false, error: API_LIMIT_MESSAGE }); + + await expect(keylessGet('/v2/research/search?q=test')).rejects.toThrow( + CLI_SIGNUP_URL + ); + }); +}); diff --git a/src/commands/interact.ts b/src/commands/interact.ts index e695e5d86c..346250122c 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -3,7 +3,7 @@ * Execute AI prompts or code against a scraped page in a live browser session */ -import { getClient, isKeylessMode } from '../utils/client'; +import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { getScrapeId, @@ -100,8 +100,10 @@ export async function handleInteractExecute( if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - (errorData as any).error || - `HTTP ${response.status}: ${response.statusText}` + withCliSignupTag( + (errorData as any).error || + `HTTP ${response.status}: ${response.statusText}` + ) ); } @@ -167,8 +169,10 @@ export async function handleInteractStop( if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - (errorData as any).error || - `HTTP ${response.status}: ${response.statusText}` + withCliSignupTag( + (errorData as any).error || + `HTTP ${response.status}: ${response.statusText}` + ) ); } diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 4aa832fd9f..6082940a20 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import type { FormatOption } from 'firecrawl'; import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; -import { getClient, isKeylessMode } from '../utils/client'; +import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; @@ -195,9 +195,10 @@ export async function executeParse( const payload = (await response.json().catch(() => ({}))) as any; if (!response.ok || payload?.success === false) { - const message = + const message = withCliSignupTag( payload?.error || - `HTTP ${response.status}: ${response.statusText || 'Request failed'}`; + `HTTP ${response.status}: ${response.statusText || 'Request failed'}` + ); return { success: false, error: message }; } diff --git a/src/utils/client.ts b/src/utils/client.ts index 6519495bfc..b6844bf142 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -29,6 +29,17 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean { return !getApiKey(apiKey) && !isCustomApiUrl(apiUrl); } +/** + * The API's keyless prompts link to signup tagged `utm_medium=api`. Retag them + * as `cli` so accounts created from the CLI are attributed to the CLI. + */ +export function withCliSignupTag(message: string): string { + return message.replaceAll( + 'utm_source=keyless&utm_medium=api', + 'utm_source=keyless&utm_medium=cli' + ); +} + export async function keylessRequest( path: string, body: Record @@ -42,7 +53,9 @@ export async function keylessRequest( const json: any = await response.json().catch(() => ({})); if (!response.ok) { throw new Error( - json?.error || `Firecrawl request failed (HTTP ${response.status})` + withCliSignupTag( + json?.error || `Firecrawl request failed (HTTP ${response.status})` + ) ); } return json; @@ -57,7 +70,9 @@ export async function keylessGet(path: string): Promise { const json: any = await response.json().catch(() => ({})); if (!response.ok) { throw new Error( - json?.error || `Firecrawl request failed (HTTP ${response.status})` + withCliSignupTag( + json?.error || `Firecrawl request failed (HTTP ${response.status})` + ) ); } return json;