diff --git a/README.md b/README.md index 44e9d030e..26bbd3749 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ firecrawl setup mcp To make Firecrawl the default web provider for supported AI agents: ```bash -firecrawl setup defaults +firecrawl setup defaults # or: firecrawl make default ``` This disables native web fetch/search where supported so agents route web work diff --git a/package.json b/package.json index 367b300a5..2d4b10809 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.15", + "version": "1.19.16", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index a464a072e..9de35141d 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'fs'; import os from 'os'; import path from 'path'; import { + handleMakeDefaultCommand, handleSetupCommand, installHermesMcp, installOpenClawMcp, @@ -73,6 +74,15 @@ describe('handleSetupCommand', () => { ); }); + it('configures Firecrawl as the default web provider via make default', async () => { + await handleMakeDefaultCommand({ yes: true }); + + expect(configureWebDefaults).toHaveBeenCalledWith({ + undo: false, + agents: undefined, + }); + }); + it('installs the default setup bundle with --yes', async () => { await handleSetupCommand(undefined, { yes: true }); diff --git a/src/__tests__/utils/update-notice.test.ts b/src/__tests__/utils/update-notice.test.ts new file mode 100644 index 000000000..58546077e --- /dev/null +++ b/src/__tests__/utils/update-notice.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import packageJson from '../../../package.json'; +import { + maybeShowUpdateNotice, + formatUpdateNotice, + type UpdateNoticeOptions, +} from '../../utils/update-notice'; +import { getLatestVersion } from '../../utils/npm-registry'; + +vi.mock('../../utils/npm-registry', async () => { + const actual = await vi.importActual< + typeof import('../../utils/npm-registry') + >('../../utils/npm-registry'); + return { + ...actual, + getLatestVersion: vi.fn(), + }; +}); + +describe('update notice', () => { + let tmpDir: string; + let originalNoUpdateCheck: string | undefined; + let write: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firecrawl-update-test-')); + originalNoUpdateCheck = process.env.FIRECRAWL_NO_UPDATE_CHECK; + delete process.env.FIRECRAWL_NO_UPDATE_CHECK; + vi.mocked(getLatestVersion).mockReset(); + write = vi.fn(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (originalNoUpdateCheck === undefined) { + delete process.env.FIRECRAWL_NO_UPDATE_CHECK; + } else { + process.env.FIRECRAWL_NO_UPDATE_CHECK = originalNoUpdateCheck; + } + vi.restoreAllMocks(); + }); + + function stderr(isTTY = true): UpdateNoticeOptions['stderr'] { + return { isTTY, write: write as unknown as NodeJS.WriteStream['write'] }; + } + + it('formats a bordered update notice', () => { + const notice = formatUpdateNotice('99.99.99'); + + expect(notice).toContain( + `✨ Update available! ${packageJson.version} -> 99.99.99` + ); + expect(notice).toContain('Run npm install -g firecrawl-cli to update.'); + expect(notice).toContain( + 'https://github.com/firecrawl/cli/releases/latest' + ); + expect(notice.startsWith('╭')).toBe(true); + expect(notice.endsWith('╯')).toBe(true); + }); + + it('does not check or print outside a TTY', async () => { + await maybeShowUpdateNotice({ cacheDir: tmpDir, stderr: stderr(false) }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it('prints a cached newer version', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + // The notice colorizes "Update available!" with ANSI codes on a TTY, so + // assert on the (uncolorized) version portion that survives formatting. + expect(write).toHaveBeenCalledWith( + expect.stringContaining(`${packageJson.version} -> 99.99.99`) + ); + }); + + it('does not print the same update twice within 12 hours', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T12:30:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it('prints the same update again after 12 hours', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T00:00:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(write).toHaveBeenCalledWith(expect.stringContaining('99.99.99')); + }); + + it('prints a different newer version even inside the cooldown', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '100.0.0', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T12:30:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(write).toHaveBeenCalledWith(expect.stringContaining('100.0.0')); + }); + + it('refreshes a stale cache from npm', async () => { + vi.mocked(getLatestVersion).mockResolvedValue({ + version: '99.99.99', + unreachable: false, + }); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).toHaveBeenCalledWith('firecrawl-cli', 750); + expect(write).toHaveBeenCalledWith(expect.stringContaining('99.99.99')); + + const cached = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'update-check.json'), 'utf-8') + ); + expect(cached.latestVersion).toBe('99.99.99'); + }); + + it('respects FIRECRAWL_NO_UPDATE_CHECK', async () => { + process.env.FIRECRAWL_NO_UPDATE_CHECK = '1'; + + await maybeShowUpdateNotice({ cacheDir: tmpDir, stderr: stderr() }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 6d1ad3236..2d55554f5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -232,6 +232,9 @@ function printNextSteps( ` ${arrow} ${dim}Default web:${reset} ${bold}firecrawl setup defaults${reset}` ); } + console.log( + ` ${arrow} ${dim}Make default:${reset} ${bold}firecrawl make default${reset}` + ); console.log( ` ${arrow} ${dim}All commands:${reset} ${bold}firecrawl --help${reset}` ); diff --git a/src/index.ts b/src/index.ts index 96032696b..4fd1e7de4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,7 +57,7 @@ import { findTemplate, stepAuth, } from './commands/init'; -import { handleSetupCommand } from './commands/setup'; +import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; @@ -66,6 +66,7 @@ import { isUrl, normalizeUrl } from './utils/url'; import { parseScrapeOptions } from './utils/options'; import { isJobId } from './utils/job'; import { ensureAuthenticated, printBanner } from './utils/auth'; +import { maybeShowUpdateNotice } from './utils/update-notice'; import packageJson from '../package.json'; import type { SearchSource, SearchCategory } from './types/search'; import type { ScrapeFormat } from './types/scrape'; @@ -2194,6 +2195,27 @@ program await handleSetupCommand(subcommand, options); }); +program + .command('make') + .description('Make Firecrawl the default provider for supported workflows') + .argument('', 'What to make default: "default"') + .option( + '--undo', + 'Undo default provider config by re-enabling native web tools where supported' + ) + .action(async (target, options) => { + if (target !== 'default') { + console.error(`Unknown make target: ${target}`); + console.log('\nAvailable targets:'); + console.log( + ' default Make Firecrawl the default web provider for supported AI agents' + ); + process.exit(1); + } + + await handleMakeDefaultCommand(options); + }); + program .command('launch') .alias('launcher') @@ -2332,6 +2354,8 @@ async function main() { return; } + await maybeShowUpdateNotice(); + // If no arguments or just help flags, check auth and show appropriate message if (args.length === 0) { const { isAuthenticated } = await import('./utils/auth'); diff --git a/src/utils/update-notice.ts b/src/utils/update-notice.ts new file mode 100644 index 000000000..be32f1767 --- /dev/null +++ b/src/utils/update-notice.ts @@ -0,0 +1,177 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import packageJson from '../../package.json'; +import { compareVersions, getLatestVersion } from './npm-registry'; + +const CACHE_FILENAME = 'update-check.json'; +const CACHE_TTL_MS = 20 * 60 * 60 * 1000; +const NOTICE_TTL_MS = 12 * 60 * 60 * 1000; +const RELEASE_NOTES_URL = 'https://github.com/firecrawl/cli/releases/latest'; +const INSTALL_COMMAND = 'npm install -g firecrawl-cli'; + +interface UpdateCache { + latestVersion: string; + checkedAt: string; + lastShownVersion?: string; + lastShownAt?: string; +} + +export interface UpdateNoticeOptions { + cacheDir?: string; + now?: Date; + stderr?: Pick; +} + +function getCachePath(cacheDir?: string): string { + return path.join( + cacheDir ?? path.join(os.homedir(), '.firecrawl'), + CACHE_FILENAME + ); +} + +function updateCheckDisabled(): boolean { + const value = process.env.FIRECRAWL_NO_UPDATE_CHECK; + return value === '1' || value === 'true'; +} + +function supportsColor(stderr: Pick): boolean { + if (process.env.NO_COLOR !== undefined) return false; + if (process.env.FORCE_COLOR !== undefined) + return process.env.FORCE_COLOR !== '0'; + return Boolean(stderr.isTTY); +} + +async function readCachedLatest( + cachePath: string, + now: Date +): Promise<{ + latestVersion?: string; + checkedAt?: string; + stale: boolean; + lastShownVersion?: string; + lastShownAt?: string; +}> { + try { + const raw = await fs.readFile(cachePath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.latestVersion !== 'string' || + typeof parsed.checkedAt !== 'string' + ) { + return { stale: true }; + } + + const checkedAt = new Date(parsed.checkedAt).getTime(); + const stale = + !Number.isFinite(checkedAt) || now.getTime() - checkedAt > CACHE_TTL_MS; + return { + latestVersion: parsed.latestVersion, + checkedAt: parsed.checkedAt, + stale, + lastShownVersion: parsed.lastShownVersion, + lastShownAt: parsed.lastShownAt, + }; + } catch { + return { stale: true }; + } +} + +async function writeCachedLatest( + cachePath: string, + cache: UpdateCache +): Promise { + try { + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, `${JSON.stringify(cache)}\n`, 'utf-8'); + } catch { + // Update checks should never make a real command fail. + } +} + +function colorize(message: string, stderr: Pick) { + if (!supportsColor(stderr)) return message; + return message + .replace('Update available!', '\x1b[1;36mUpdate available!\x1b[0m') + .replace(INSTALL_COMMAND, `\x1b[36m${INSTALL_COMMAND}\x1b[0m`) + .replace(RELEASE_NOTES_URL, `\x1b[36;4m${RELEASE_NOTES_URL}\x1b[0m`); +} + +export function formatUpdateNotice(latestVersion: string): string { + const currentVersion = packageJson.version; + const lines = [ + `✨ Update available! ${currentVersion} -> ${latestVersion}`, + `Run ${INSTALL_COMMAND} to update.`, + '', + 'See full release notes:', + RELEASE_NOTES_URL, + ]; + const width = Math.max(...lines.map((line) => line.length)); + const top = `╭${'─'.repeat(width + 2)}╮`; + const bottom = `╰${'─'.repeat(width + 2)}╯`; + const body = lines.map((line) => `│ ${line.padEnd(width)} │`); + return [top, ...body, bottom].join('\n'); +} + +function shouldShowNotice(latestVersion: string | undefined): boolean { + return ( + typeof latestVersion === 'string' && + compareVersions(packageJson.version, latestVersion) < 0 + ); +} + +function noticeWasRecentlyShown( + cache: { + lastShownVersion?: string; + lastShownAt?: string; + }, + latestVersion: string, + now: Date +): boolean { + if (cache.lastShownVersion !== latestVersion || !cache.lastShownAt) { + return false; + } + + const lastShownAt = new Date(cache.lastShownAt).getTime(); + return ( + Number.isFinite(lastShownAt) && now.getTime() - lastShownAt <= NOTICE_TTL_MS + ); +} + +export async function maybeShowUpdateNotice( + options: UpdateNoticeOptions = {} +): Promise { + const stderr = options.stderr ?? process.stderr; + if (!stderr.isTTY || updateCheckDisabled()) return; + + const now = options.now ?? new Date(); + const cachePath = getCachePath(options.cacheDir); + const cached = await readCachedLatest(cachePath, now); + + let latestVersion = cached.latestVersion; + let checkedAt = cached.checkedAt; + if (cached.stale) { + const latest = await getLatestVersion(packageJson.name, 750); + if (!latest.unreachable && latest.version) { + latestVersion = latest.version; + checkedAt = now.toISOString(); + await writeCachedLatest(cachePath, { + latestVersion: latest.version, + checkedAt, + lastShownVersion: cached.lastShownVersion, + lastShownAt: cached.lastShownAt, + }); + } + } + + if (!shouldShowNotice(latestVersion) || latestVersion === undefined) return; + if (noticeWasRecentlyShown(cached, latestVersion, now)) return; + + stderr.write(`${colorize(formatUpdateNotice(latestVersion), stderr)}\n\n`); + await writeCachedLatest(cachePath, { + latestVersion, + checkedAt: checkedAt ?? now.toISOString(), + lastShownVersion: latestVersion, + lastShownAt: now.toISOString(), + }); +} diff --git a/src/utils/web-defaults.ts b/src/utils/web-defaults.ts index 87734ed2d..1419acf6a 100644 --- a/src/utils/web-defaults.ts +++ b/src/utils/web-defaults.ts @@ -78,12 +78,12 @@ async function configureClaudeDefaults( (tool) => typeof tool !== 'string' || !denyTools.has(tool) ); } else { - const existing = new Set( + const existingDeny = new Set( deny.filter((tool): tool is string => typeof tool === 'string') ); nextDeny = [...deny]; for (const tool of CLAUDE_DENY_TOOLS) { - if (!existing.has(tool)) nextDeny.push(tool); + if (!existingDeny.has(tool)) nextDeny.push(tool); } }