diff --git a/src/__tests__/commands/sql.test.ts b/src/__tests__/commands/sql.test.ts new file mode 100644 index 0000000000..ab5cf6d195 --- /dev/null +++ b/src/__tests__/commands/sql.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { Readable } from 'node:stream'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSqlCommand, readSqlInput } from '../../commands/sql'; +import { handleAlexandria } from '../../commands/alexandria'; + +vi.mock('../../commands/alexandria', () => ({ handleAlexandria: vi.fn() })); +afterEach(() => vi.clearAllMocks()); + +it.each([false, true])('forwards raw SQL with execute=%s', async (execute) => { + const query = 'SELECT * FROM "example/tool" WHERE name = \'Nike\' LIMIT 1'; + await createSqlCommand().parseAsync( + [ + query, + ...(execute ? ['--execute'] : []), + '--pretty', + '--request-id', + 'request', + ], + { from: 'user' } + ); + expect(handleAlexandria).toHaveBeenCalledWith( + [{ provider: 'firecrawl', capability: 'sql', options: { query, execute } }], + expect.objectContaining({ pretty: true, requestId: 'request' }) + ); +}); + +it('reads multiline stdin without changing quotes', async () => { + const query = + 'WITH a AS (\n SELECT * FROM "a/b" LIMIT 1\n) SELECT * FROM a LIMIT 1'; + expect(await readSqlInput(undefined, undefined, Readable.from([query]))).toBe( + query + ); +}); + +it('reads a file and forwards it to the existing handler', async () => { + const dir = await mkdtemp(join(tmpdir(), 'sql-cli-')); + try { + const path = join(dir, 'query.sql'); + await writeFile(path, 'SHOW TABLES LIMIT 1'); + await createSqlCommand().parseAsync(['--file', path], { from: 'user' }); + expect(handleAlexandria).toHaveBeenCalledWith( + [ + { + provider: 'firecrawl', + capability: 'sql', + options: { query: 'SHOW TABLES LIMIT 1', execute: false }, + }, + ], + expect.anything() + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +it('rejects ambiguous sources without dispatch', async () => { + await expect( + createSqlCommand().parseAsync(['HELP', '--file', 'query.sql'], { + from: 'user', + }) + ).rejects.toThrow('either'); + expect(handleAlexandria).not.toHaveBeenCalled(); +}); + +it('rejects missing, empty and oversized input', async () => { + await expect( + readSqlInput( + undefined, + undefined, + Object.assign(Readable.from([]), { isTTY: true }) + ) + ).rejects.toThrow('Provide'); + await expect( + readSqlInput(undefined, undefined, Readable.from([' '])) + ).rejects.toThrow('empty'); + await expect(readSqlInput('x'.repeat(16001), undefined)).rejects.toThrow( + '16,000' + ); + await expect( + readSqlInput(undefined, undefined, Readable.from(['x'.repeat(16001)])) + ).rejects.toThrow('16,000'); +}); diff --git a/src/commands/sql.ts b/src/commands/sql.ts new file mode 100644 index 0000000000..a2fedadab9 --- /dev/null +++ b/src/commands/sql.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { readFile } from 'node:fs/promises'; +import { handleAlexandria } from './alexandria'; + +export async function readSqlInput( + query: string | undefined, + file: string | undefined, + stdin: AsyncIterable & { isTTY?: boolean } = process.stdin +): Promise { + if (query !== undefined && file !== undefined) + throw new Error('Use either a query or --file.'); + if (file !== undefined) query = await readFile(file, 'utf8'); + if (query === undefined) { + if (stdin.isTTY) throw new Error('Provide a query, --file, or stdin.'); + query = ''; + for await (const chunk of stdin) { + query += chunk.toString(); + if (query.length > 16000) + throw new Error('SQL must be at most 16,000 characters.'); + } + } + if (!query.trim()) throw new Error('SQL must not be empty.'); + if (query.length > 16000) + throw new Error('SQL must be at most 16,000 characters.'); + return query; +} + +export function createSqlCommand(): Command { + return new Command('sql') + .description('Experimental Alexandria syntax') + .argument('[query]', 'SQL statement') + .option('-f, --file ', 'Read SQL from a file') + .option( + '--execute', + 'Execute paid provider calls; defaults to preview', + false + ) + .option('-k, --api-key ', 'Firecrawl API key') + .option('--api-url ', 'Firecrawl API URL') + .option('--request-id ', 'Request ID') + .option('-o, --output ', 'Output file') + .option('--json', 'Output JSON') + .option('--pretty', 'Format JSON') + .action(async (query, options) => { + const statement = await readSqlInput(query, options.file); + await handleAlexandria( + [ + { + provider: 'firecrawl', + capability: 'sql', + options: { query: statement, execute: options.execute }, + }, + ], + options + ); + }); +} diff --git a/src/index.ts b/src/index.ts index d9416f6284..b2671309e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import { Command, Option } from 'commander'; +import { createSqlCommand } from './commands/sql'; import { addFormatsAlias } from './utils/format-option'; import { addAlexandriaScrapeOptions, @@ -2252,6 +2253,7 @@ Shorthand: "firecrawl x" is an alias for "firecrawl experimental". ` ); experimental.addCommand(createDownloadCommand()); +experimental.addCommand(createSqlCommand(), { hidden: true }); program.addCommand(experimental); program