Skip to content
Merged
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
85 changes: 85 additions & 0 deletions src/__tests__/commands/sql.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
57 changes: 57 additions & 0 deletions src/commands/sql.ts
Original file line number Diff line number Diff line change
@@ -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<string | Buffer> & { isTTY?: boolean } = process.stdin
): Promise<string> {
if (query !== undefined && file !== undefined)
throw new Error('Use either a query or --file.');
if (file !== undefined) query = await readFile(file, 'utf8');

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: --file loads the entire file before enforcing the 16,000-character limit, so a large path can exhaust memory or hang instead of being rejected. Read at most 16,001 characters from the file and reject oversized input before accumulating it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/sql.ts, line 12:

<comment>`--file` loads the entire file before enforcing the 16,000-character limit, so a large path can exhaust memory or hang instead of being rejected. Read at most 16,001 characters from the file and reject oversized input before accumulating it.</comment>

<file context>
@@ -0,0 +1,57 @@
+): Promise<string> {
+  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.');
</file context>
Fix with cubic

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 <path>', 'Read SQL from a file')
.option(
'--execute',
'Execute paid provider calls; defaults to preview',
false
)
.option('-k, --api-key <key>', 'Firecrawl API key')
.option('--api-url <url>', 'Firecrawl API URL')
.option('--request-id <id>', 'Request ID')
.option('-o, --output <path>', '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
);
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { Command, Option } from 'commander';
import { createSqlCommand } from './commands/sql';
import { addFormatsAlias } from './utils/format-option';
import {
addAlexandriaScrapeOptions,
Expand Down Expand Up @@ -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
Expand Down
Loading