From 10dfa52bd54c862d28a039918d097026e5c26cbc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:09:29 +0800 Subject: [PATCH 01/13] feat(cli): add getDatabaseUrl client method and --remote on execute/studio - Add PlatformClient.getDatabaseUrl(projectId) that GETs /projects/:id/database-url and delegates errors to throwPlatformApiError. - Add --remote boolean flag to db execute and db studio subcommand arg shapes (DbArgs union + parseDbArgs + help text). - Unit tests: get-database-url.test.ts covers success, 404, 403, and no-body invariants; db-args.test.ts extended for --remote on execute and studio. Co-Authored-By: Claude Sonnet 4.6 --- packages/void/src/cli/client.ts | 14 +++ packages/void/src/cli/db-args.ts | 23 +++-- packages/void/test/unit/db-args.test.ts | 17 +++- .../void/test/unit/get-database-url.test.ts | 88 +++++++++++++++++++ 4 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 packages/void/test/unit/get-database-url.test.ts diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index b0fc9a925..a71dbabc3 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -789,6 +789,20 @@ export class PlatformClient { return res.json(); } + async getDatabaseUrl(projectId: string): Promise<{ url: string }> { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/database-url`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to fetch database URL', res); + } + + return res.json(); + } + async purgeCache(projectId: string): Promise<{ purged: number }> { const res = await fetch(`${this.baseUrl}/projects/${projectId}/revalidate`, { method: 'POST', diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index f6d136db0..27ed05472 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -11,13 +11,22 @@ export const dbSubcommands: Array = [ description: 'Reset local DB, run migrations, and seed', flags: [{ name: '--file', arg: '', description: 'Seed from a specific file' }], }, - { name: 'execute', arg: '', description: 'Run SQL against the local database' }, + { + name: 'execute', + arg: '', + description: 'Run SQL against the local database', + flags: [{ name: '--remote', description: 'Run against the remote database' }], + }, { name: 'migrate', description: 'Apply pending migrations', flags: [{ name: '--remote', description: 'Apply to the remote database' }], }, - { name: 'studio', description: 'Open Drizzle Studio for the local database' }, + { + name: 'studio', + description: 'Open Drizzle Studio for the local database', + flags: [{ name: '--remote', description: 'Open Studio against the remote database' }], + }, { name: 'set-url', description: 'Update the PostgreSQL connection string for deployment' }, { name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' }, { @@ -38,9 +47,9 @@ export type DbArgs = | { action: 'status' } | { action: 'reset' } | { action: 'seed'; file?: string } - | { action: 'execute'; command?: string; file?: string } + | { action: 'execute'; command?: string; file?: string; remote?: boolean } | { action: 'migrate'; remote?: boolean } - | { action: 'studio' } + | { action: 'studio'; remote?: boolean } | { action: 'export'; output?: string; @@ -116,7 +125,8 @@ export function parseDbArgs(args: Array): DbArgs { if (command && file) { throw new Error('db: Provide either SQL inline or --file, not both.'); } - return { action, command, file }; + const remote = parsed.values.remote === true ? true : undefined; + return { action, command, file, remote }; } if (action === 'migrate') { @@ -131,7 +141,8 @@ export function parseDbArgs(args: Array): DbArgs { if (rest.length > 0) { throw new Error(`db: Unexpected argument '${rest[0]}'.`); } - return { action }; + const remote = parsed.values.remote === true ? true : undefined; + return { action, remote }; } if (action === 'set-url') { diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 1549e22d0..803da1ee4 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -33,6 +33,7 @@ describe('parseDbArgs', () => { action: 'execute', command: 'SELECT * FROM users', file: undefined, + remote: undefined, }); }); @@ -41,6 +42,16 @@ describe('parseDbArgs', () => { action: 'execute', command: undefined, file: 'query.sql', + remote: undefined, + }); + }); + + it('parses execute with --remote flag', () => { + expect(parseDbArgs(['execute', 'SELECT 1', '--remote'])).toEqual({ + action: 'execute', + command: 'SELECT 1', + file: undefined, + remote: true, }); }); @@ -147,7 +158,11 @@ describe('parseDbArgs', () => { }); it('parses studio', () => { - expect(parseDbArgs(['studio'])).toEqual({ action: 'studio' }); + expect(parseDbArgs(['studio'])).toEqual({ action: 'studio', remote: undefined }); + }); + + it('parses studio with --remote flag', () => { + expect(parseDbArgs(['studio', '--remote'])).toEqual({ action: 'studio', remote: true }); }); it('errors on studio with extra positional', () => { diff --git a/packages/void/test/unit/get-database-url.test.ts b/packages/void/test/unit/get-database-url.test.ts new file mode 100644 index 000000000..4c232223a --- /dev/null +++ b/packages/void/test/unit/get-database-url.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformClient, PlatformApiError } from '../../src/cli/client.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +const apiUrl = 'https://api.test'; +const projectId = 'proj_abc'; +const token = 'test-token'; + +function makeClient(): PlatformClient { + return new PlatformClient(token, { apiUrl }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('PlatformClient.getDatabaseUrl', () => { + it('GETs the correct URL with auth headers', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ url: 'postgres://user:pass@host/db' })); + + const client = makeClient(); + await client.getDatabaseUrl(projectId); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/projects/${projectId}/database-url`); + expect((init.method ?? 'GET').toUpperCase()).toBe('GET'); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + }); + + it('returns the url from a successful response', async () => { + const expected = 'postgres://user:pass@host:5432/mydb'; + fetchMock.mockResolvedValueOnce(jsonResponse({ url: expected })); + + const client = makeClient(); + const result = await client.getDatabaseUrl(projectId); + + expect(result).toEqual({ url: expected }); + }); + + it('throws PlatformApiError on 404 (database_url_not_stored)', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: 'database_url_not_stored', message: 'No database URL stored.' }, 404), + ); + + const client = makeClient(); + const err = await client.getDatabaseUrl(projectId).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PlatformApiError); + expect((err as PlatformApiError).status).toBe(404); + }); + + it('throws PlatformApiError on 403 (ownership check failure)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'forbidden' }, 403)); + + const client = makeClient(); + const err = await client.getDatabaseUrl(projectId).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PlatformApiError); + expect((err as PlatformApiError).status).toBe(403); + }); + + it('does not send a request body', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ url: 'postgres://host/db' })); + + const client = makeClient(); + await client.getDatabaseUrl(projectId); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBeUndefined(); + }); +}); From a2a702123bb810f2b837af2797c2b27ad3b2775f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:18:42 +0800 Subject: [PATCH 02/13] feat(cli): wire --remote into db execute for D1 and PG - D1 path POSTs to ${proxyUrl}/d1/query with token, binding (auto-discovered via getResources, defaulting to DB), and cfAccessHeaders; errors on --file - PG path calls getDatabaseUrl to fetch the connection string, then connects directly with pg.Client; surfaces a friendly message on 404 - Safety guard prompts on destructive statements (non-SELECT/EXPLAIN/WITH) when stdin is a TTY; skipped on non-TTY and when --yes is implied by non-interactive - isSafeStatement / confirmDestructiveRemote helpers keep the logic clean - Updated execute subcommand description in db-args.ts to reflect --remote - 22 unit tests in db-execute-remote.test.ts covering D1 and PG paths, error cases (404, non-OK proxy, missing token, unlinked project), TTY prompt, table rendering, and regression for the non-remote local path --- packages/void/src/cli/db-args.ts | 4 +- packages/void/src/cli/db.ts | 151 +++++- .../void/test/unit/db-execute-remote.test.ts | 477 ++++++++++++++++++ 3 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 packages/void/test/unit/db-execute-remote.test.ts diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 27ed05472..34917859c 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -14,8 +14,8 @@ export const dbSubcommands: Array = [ { name: 'execute', arg: '', - description: 'Run SQL against the local database', - flags: [{ name: '--remote', description: 'Run against the remote database' }], + description: 'Run SQL against the database (use --remote for the deployed database)', + flags: [{ name: '--remote', description: 'Run against the deployed database' }], }, { name: 'migrate', diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index c8ae7a557..aa46635eb 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { intro, outro, log } from '@clack/prompts'; +import { confirm, intro, isCancel, outro, log } from '@clack/prompts'; import { extname, join, relative, resolve } from 'pathe'; import { readConfig, getDatabaseDialect, type DatabaseDialect } from '../config.ts'; import { @@ -18,8 +18,10 @@ import { import { detectFramework } from '../plugin-inference.ts'; import { readProjectPaths } from '../project-paths.ts'; import type { SeedFn } from '../runtime/seed.ts'; -import { getToken } from './auth.ts'; -import { PlatformClient } from './client.ts'; +import { cfAccessHeaders } from '../shared/cf-access.ts'; +import { DEFAULT_PROXY_URL, STAGING_PROXY_URL } from '../shared/proxy.ts'; +import { getToken, isStagingMode } from './auth.ts'; +import { PlatformApiError, PlatformClient } from './client.ts'; import type { DbArgs } from './db-args.ts'; import { formatTimestamp } from './gen.ts'; import { cliTitle, dim, pc } from './output.ts'; @@ -148,7 +150,7 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } else if (args.action === 'seed') { await runSeed(root, args.file, dialect); } else if (args.action === 'execute') { - await runExecute(root, args.command, args.file, dialect); + await runExecute(root, args.command, args.file, dialect, args.remote); } else if (args.action === 'migrate') { await runMigrate(root, dialect, args.remote); } else if (args.action === 'studio') { @@ -405,12 +407,153 @@ async function runSeed( outro('Done.'); } +/** + * Returns true when the SQL is read-only (SELECT / EXPLAIN / WITH). + * Used to skip the destructive-statement safety prompt for safe queries. + */ +function isSafeStatement(sql: string): boolean { + return /^\s*(SELECT|EXPLAIN|WITH)\b/i.test(sql); +} + +/** + * Prompt the user to confirm a destructive remote query when running in a TTY. + * Returns true if execution should proceed, false if it was cancelled. + */ +async function confirmDestructiveRemote(sql: string): Promise { + if (isSafeStatement(sql)) { + return true; + } + if (!process.stdin.isTTY) { + return true; + } + const ok = await confirm({ + message: 'This will run against your deployed database. Continue?', + }); + if (isCancel(ok) || !ok) { + return false; + } + return true; +} + async function runExecute( root: string, command: string | undefined, file: string | undefined, dialect: DatabaseDialect, + remote?: boolean, ): Promise { + if (remote) { + const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; + + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + + const projectConfig = readProjectConfig(root); + if (!projectConfig) { + log.error('No project linked. Run `void deploy` first or link a project.'); + process.exit(1); + } + + const shouldProceed = await confirmDestructiveRemote(sql); + if (!shouldProceed) { + log.info('Cancelled.'); + process.exit(0); + } + + if (dialect === 'postgresql') { + // PG remote: fetch the connection string from the platform, then connect directly. + const client = new PlatformClient(token, { root }); + let dbUrl: string; + try { + const result = await client.getDatabaseUrl(projectConfig.projectId); + dbUrl = result.url; + } catch (err) { + if (err instanceof PlatformApiError && err.status === 404) { + log.error( + "This project's database URL isn't stored on the platform yet. Run `void db set-url` once to populate it, then retry.", + ); + process.exit(1); + } + throw err; + } + + const { Client } = await import('pg'); + const pgClient: PgClient = new Client({ connectionString: dbUrl }); + await pgClient.connect(); + try { + const result = await pgClient.query(sql); + if (result.rows && result.rows.length > 0) { + printTable(result.rows); + } else { + log.success('Executed successfully.'); + } + } finally { + await pgClient.end(); + } + outro('Done.'); + return; + } + + // D1 remote: route through the platform proxy. + if (file) { + log.error( + '--file is not yet supported with --remote D1; pipe statements through stdin or run them one at a time.', + ); + process.exit(1); + } + + const platformClient = new PlatformClient(token, { root }); + let binding = 'DB'; + try { + const resources = await platformClient.getResources(projectConfig.projectId); + const d1Resource = resources.find((r) => r.type === 'd1'); + if (d1Resource) { + binding = d1Resource.binding_name; + } + } catch { + // Fall back to default binding 'DB' if resource discovery fails + } + + const proxyUrl = isStagingMode(root) ? STAGING_PROXY_URL : DEFAULT_PROXY_URL; + const res = await fetch(`${proxyUrl}/d1/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...cfAccessHeaders(process.env), + }, + body: JSON.stringify({ + projectId: projectConfig.projectId, + binding, + sql, + params: [], + method: 'all', + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + log.error(`db: Proxy request failed with ${res.status}: '${text}'.`); + process.exit(1); + } + + const data = (await res.json()) as { + results?: Array>; + success?: boolean; + }; + const rows = data.results ?? []; + if (rows.length > 0) { + printTable(rows); + } else { + log.success('Executed successfully.'); + } + outro('Done.'); + return; + } + if (dialect === 'postgresql') { const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; await withPgClient(root, async (client) => { diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts new file mode 100644 index 000000000..2329f31f0 --- /dev/null +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -0,0 +1,477 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +// ---- hoisted mocks --------------------------------------------------------- + +const mockGetToken = vi.fn<() => string | null>(() => null); +const mockReadProjectConfig = vi.fn<() => { projectId: string; slug: string } | null>(() => null); +const mockIsStagingMode = vi.fn<() => boolean>(() => false); + +const mockGetResources = vi.fn<() => Promise>>(() => + Promise.resolve([]), +); +const mockGetDatabaseUrl = vi.fn<() => Promise<{ url: string }>>(); + +vi.mock('../../src/cli/auth', () => ({ + getToken: () => mockGetToken(), + isStagingMode: () => mockIsStagingMode(), +})); + +vi.mock('../../src/cli/project', () => ({ + readProjectConfig: () => mockReadProjectConfig(), + isVoidInternalStaging: () => false, +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + getResources() { + return mockGetResources(); + } + getDatabaseUrl() { + return mockGetDatabaseUrl(); + } + }, + PlatformApiError: class extends Error { + status: number; + body: string; + constructor(action: string, status: number, body: string) { + super(`${action}: ${status} ${body}`); + this.status = status; + this.body = body; + } + }, +})); + +const mockPgConnect = vi.fn<() => Promise>(() => Promise.resolve()); +const mockPgEnd = vi.fn<() => Promise>(() => Promise.resolve()); +const mockPgQuery = vi.fn<() => Promise<{ rows: Array> }>>(() => + Promise.resolve({ rows: [] }), +); + +vi.mock('pg', () => ({ + Client: class { + connect = mockPgConnect; + end = mockPgEnd; + query = mockPgQuery; + }, +})); + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +const mockConfirm = vi.fn<() => Promise>(); +const mockIsCancel = vi.fn<(value: unknown) => boolean>((v) => typeof v === 'symbol'); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: (...args: Array) => mockConfirm(...args), + isCancel: (v: unknown) => mockIsCancel(v), + log: { + error: vi.fn(), + info: vi.fn(), + step: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const roots: Array = []; + +function createProjectRoot(options: { database?: 'pg' } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'void-db-execute-remote-')); + roots.push(root); + + const migrationsDir = join(root, 'db', 'migrations'); + mkdirSync(join(migrationsDir, 'meta'), { recursive: true }); + + const voidConfig = options.database === 'pg' ? { database: 'pg' } : {}; + writeFileSync(join(root, 'void.json'), JSON.stringify(voidConfig)); + + // .void/project.json to satisfy readProjectConfig (the REAL one is mocked, + // so this is just to ensure the root is a plausible project dir) + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ projectId: 'proj-abc', slug: 'test-proj' }), + ); + + return root; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('void db execute --remote (D1)', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj-abc', slug: 'test-proj' }); + mockIsStagingMode.mockReturnValue(false); + mockGetResources.mockResolvedValue([]); + // By default, confirm returns true (proceed) + mockConfirm.mockResolvedValue(true); + mockIsCancel.mockImplementation((v) => typeof v === 'symbol'); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalFetch) { + globalThis.fetch = originalFetch; + } + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('POSTs to /d1/query with correct URL, body, and auth headers', async () => { + const root = createProjectRoot(); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://proxy.void.cloud/d1/query'); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['Content-Type']).toBe('application/json'); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + projectId: 'proj-abc', + binding: 'DB', + sql: 'SELECT 1', + params: [], + method: 'all', + }); + }); + + it('uses the staging proxy URL when in staging mode', async () => { + const root = createProjectRoot(); + mockIsStagingMode.mockReturnValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://proxy.staging.void.cloud/d1/query'); + }); + + it('uses the D1 binding name from getResources when available', async () => { + const root = createProjectRoot(); + mockGetResources.mockResolvedValue([{ type: 'd1', binding_name: 'MY_DB' }]); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + + const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.binding).toBe('MY_DB'); + }); + + it('falls back to binding DB when getResources has no d1 resource', async () => { + const root = createProjectRoot(); + mockGetResources.mockResolvedValue([{ type: 'kv', binding_name: 'KV_STORE' }]); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + + const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.binding).toBe('DB'); + }); + + it('renders a table when results are returned', async () => { + const root = createProjectRoot(); + const rows = [{ id: 1, name: 'Alice' }]; + fetchMock.mockResolvedValueOnce(jsonResponse({ results: rows, success: true })); + + const consoleSpy = vi.spyOn(console, 'log'); + await runDbCommand(root, { action: 'execute', command: 'SELECT * FROM users', remote: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('id'); + expect(output).toContain('name'); + expect(output).toContain('Alice'); + }); + + it('errors when not logged in', async () => { + const root = createProjectRoot(); + mockGetToken.mockReturnValue(null); + + await expect( + runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }), + ).rejects.toThrow('process.exit:1'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('errors when no project is linked', async () => { + const root = createProjectRoot(); + mockReadProjectConfig.mockReturnValue(null); + + await expect( + runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }), + ).rejects.toThrow('process.exit:1'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('errors when --file is passed with --remote D1', async () => { + const root = createProjectRoot(); + // We need a real file for the path to be resolved before we get to the --remote check + const sqlFile = join(root, 'query.sql'); + writeFileSync(sqlFile, 'SELECT 1'); + + await expect( + runDbCommand(root, { action: 'execute', file: 'query.sql', remote: true }), + ).rejects.toThrow('process.exit:1'); + }); + + it('errors on proxy non-OK response', async () => { + const root = createProjectRoot(); + fetchMock.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })); + + await expect( + runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }), + ).rejects.toThrow('process.exit:1'); + }); + + it('prompts for confirmation on destructive statement in TTY', async () => { + const root = createProjectRoot(); + // Simulate a TTY + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'DELETE FROM users', remote: true }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + const confirmCall = mockConfirm.mock.calls[0][0] as { message: string }; + expect(confirmCall.message).toContain('deployed database'); + }); + + it('aborts when user declines destructive confirmation', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(false); + + await expect( + runDbCommand(root, { action: 'execute', command: 'DROP TABLE users', remote: true }), + ).rejects.toThrow('process.exit:0'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips confirmation for SELECT statements even in TTY', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT * FROM users', remote: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('skips confirmation for EXPLAIN statements', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN SELECT * FROM users', + remote: true, + }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('skips confirmation for UPDATE when not in TTY (non-interactive)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'DELETE FROM users', remote: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('non-remote path is unaffected (regression: local SQLite still works)', async () => { + // The non-remote path should not call fetch at all + const root = createProjectRoot(); + // The SQLite path will fail because there's no local DB, but it should + // try to open the local DB, not call fetch + await expect(runDbCommand(root, { action: 'execute', command: 'SELECT 1' })).rejects.toThrow( + 'process.exit:1', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('void db execute --remote (PostgreSQL)', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj-abc', slug: 'test-proj' }); + mockIsStagingMode.mockReturnValue(false); + mockPgConnect.mockResolvedValue(undefined); + mockPgEnd.mockResolvedValue(undefined); + mockPgQuery.mockResolvedValue({ rows: [] }); + mockConfirm.mockResolvedValue(true); + mockIsCancel.mockImplementation((v) => typeof v === 'symbol'); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalFetch) { + globalThis.fetch = originalFetch; + } + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('fetches the database URL via getDatabaseUrl and connects with it', async () => { + const root = createProjectRoot({ database: 'pg' }); + const dbUrl = 'postgres://user:pass@host:5432/mydb'; + mockGetDatabaseUrl.mockResolvedValue({ url: dbUrl }); + mockPgQuery.mockResolvedValue({ rows: [] }); + + await runDbCommand(root, { + action: 'execute', + command: 'SELECT 1', + remote: true, + }); + + expect(mockGetDatabaseUrl).toHaveBeenCalledTimes(1); + expect(mockPgConnect).toHaveBeenCalledTimes(1); + expect(mockPgQuery).toHaveBeenCalledWith('SELECT 1'); + expect(mockPgEnd).toHaveBeenCalledTimes(1); + }); + + it('renders a table when rows are returned', async () => { + const root = createProjectRoot({ database: 'pg' }); + mockGetDatabaseUrl.mockResolvedValue({ url: 'postgres://host/db' }); + mockPgQuery.mockResolvedValue({ rows: [{ id: 1, name: 'Bob' }] }); + + const consoleSpy = vi.spyOn(console, 'log'); + await runDbCommand(root, { + action: 'execute', + command: 'SELECT * FROM users', + remote: true, + }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('id'); + expect(output).toContain('name'); + expect(output).toContain('Bob'); + }); + + it('surfaces a friendly error on 404 (database URL not stored)', async () => { + const root = createProjectRoot({ database: 'pg' }); + + // Simulate the PlatformApiError that getDatabaseUrl throws on 404 + const { PlatformApiError } = await import('../../src/cli/client.ts'); + mockGetDatabaseUrl.mockRejectedValue(new PlatformApiError('test', 404, '{}')); + + const { log: clackLog } = await import('@clack/prompts'); + + await expect( + runDbCommand(root, { + action: 'execute', + command: 'SELECT 1', + remote: true, + }), + ).rejects.toThrow('process.exit:1'); + + const errorCalls = (clackLog.error as ReturnType).mock.calls + .map((c: Array) => String(c[0])) + .join('\n'); + expect(errorCalls).toContain('void db set-url'); + }); + + it('re-throws non-404 errors from getDatabaseUrl', async () => { + const root = createProjectRoot({ database: 'pg' }); + + const { PlatformApiError } = await import('../../src/cli/client.ts'); + mockGetDatabaseUrl.mockRejectedValue(new PlatformApiError('test', 403, '{}')); + + await expect( + runDbCommand(root, { + action: 'execute', + command: 'SELECT 1', + remote: true, + }), + ).rejects.toThrow(PlatformApiError); + }); + + it('prompts for confirmation on destructive statement (TTY)', async () => { + const root = createProjectRoot({ database: 'pg' }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockGetDatabaseUrl.mockResolvedValue({ url: 'postgres://host/db' }); + mockPgQuery.mockResolvedValue({ rows: [] }); + mockConfirm.mockResolvedValue(true); + + await runDbCommand(root, { + action: 'execute', + command: 'UPDATE users SET name = $1', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockPgQuery).toHaveBeenCalledTimes(1); + }); + + it('aborts destructive statement when user declines', async () => { + const root = createProjectRoot({ database: 'pg' }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(false); + + await expect( + runDbCommand(root, { + action: 'execute', + command: 'DROP TABLE users', + remote: true, + }), + ).rejects.toThrow('process.exit:0'); + + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + expect(mockPgQuery).not.toHaveBeenCalled(); + }); +}); From baf390cb40bc51ea8614fe5f9e32926a7e932616 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:23:40 +0800 Subject: [PATCH 03/13] test(cli): add WITH and cfAccessHeaders coverage for db execute --remote - Add a WITH (CTE) test case to confirm the safety prompt is skipped for statements starting with WITH, alongside the existing SELECT and EXPLAIN cases. - Add a test that stubs CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET and asserts the CF-Access-Client-Id / CF-Access-Client-Secret headers are forwarded on the POST request to the D1 proxy. Co-Authored-By: Claude Sonnet 4.6 --- .../void/test/unit/db-execute-remote.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index 2329f31f0..9a5087ac3 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -310,6 +310,50 @@ describe('void db execute --remote (D1)', () => { expect(mockConfirm).not.toHaveBeenCalled(); }); + it('skips confirmation for WITH (CTE) statements', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'WITH cte AS (SELECT 1) SELECT * FROM cte', + remote: true, + }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('includes CF Access service-token headers when env vars are set', async () => { + const root = createProjectRoot(); + const originalId = process.env.CF_ACCESS_CLIENT_ID; + const originalSecret = process.env.CF_ACCESS_CLIENT_SECRET; + process.env.CF_ACCESS_CLIENT_ID = 'test-client-id.access'; + process.env.CF_ACCESS_CLIENT_SECRET = 'test-client-secret'; + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + try { + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + } finally { + if (originalId === undefined) { + delete process.env.CF_ACCESS_CLIENT_ID; + } else { + process.env.CF_ACCESS_CLIENT_ID = originalId; + } + if (originalSecret === undefined) { + delete process.env.CF_ACCESS_CLIENT_SECRET; + } else { + process.env.CF_ACCESS_CLIENT_SECRET = originalSecret; + } + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['CF-Access-Client-Id']).toBe('test-client-id.access'); + expect(headers['CF-Access-Client-Secret']).toBe('test-client-secret'); + }); + it('skips confirmation for UPDATE when not in TTY (non-interactive)', async () => { const root = createProjectRoot(); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); From 80a24e62aab298fd1fe8cf548922808dc7a5830a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:30:30 +0800 Subject: [PATCH 04/13] fix(cli): reject --file with --remote D1 before confirming destructive prompt Move the D1 --file unsupported check to the very top of the remote branch, before reading the file or calling confirmDestructiveRemote. Add a regression test that asserts the confirmation prompt is never reached when --file is passed to a D1 remote execute. Also adds three deferral/limitation comments: - isSafeStatement: note that leading SQL comments are not stripped - confirmDestructiveRemote: note --yes flag is intentionally deferred - D1 binding resolution: TODO for --binding multi-binding support Co-Authored-By: Claude Sonnet 4.6 --- packages/void/src/cli/db.ts | 18 +++++++++++------- .../void/test/unit/db-execute-remote.test.ts | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index aa46635eb..5de339b9b 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -410,6 +410,7 @@ async function runSeed( /** * Returns true when the SQL is read-only (SELECT / EXPLAIN / WITH). * Used to skip the destructive-statement safety prompt for safe queries. + * // Note: leading SQL comments are not stripped; `-- foo\nSELECT 1` will be flagged as destructive. This is a UX nuisance, not a safety hole. */ function isSafeStatement(sql: string): boolean { return /^\s*(SELECT|EXPLAIN|WITH)\b/i.test(sql); @@ -424,6 +425,7 @@ async function confirmDestructiveRemote(sql: string): Promise { return true; } if (!process.stdin.isTTY) { + // --yes flag (skip prompt under a TTY) is intentionally not wired here — non-TTY already bypasses; --yes can be added in a follow-up if scripted-TTY callers need it. return true; } const ok = await confirm({ @@ -443,6 +445,14 @@ async function runExecute( remote?: boolean, ): Promise { if (remote) { + // Reject --file with --remote D1 before reading the file or prompting. + if (dialect !== 'postgresql' && file) { + log.error( + '--file is not yet supported with --remote D1; pipe statements through stdin or run them one at a time.', + ); + process.exit(1); + } + const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; const token = getToken(root); @@ -498,17 +508,11 @@ async function runExecute( } // D1 remote: route through the platform proxy. - if (file) { - log.error( - '--file is not yet supported with --remote D1; pipe statements through stdin or run them one at a time.', - ); - process.exit(1); - } - const platformClient = new PlatformClient(token, { root }); let binding = 'DB'; try { const resources = await platformClient.getResources(projectConfig.projectId); + // TODO: support --binding for projects with multiple D1 bindings (currently auto-selects the first). const d1Resource = resources.find((r) => r.type === 'd1'); if (d1Resource) { binding = d1Resource.binding_name; diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index 9a5087ac3..c79d027ee 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -242,15 +242,24 @@ describe('void db execute --remote (D1)', () => { it('errors when --file is passed with --remote D1', async () => { const root = createProjectRoot(); - // We need a real file for the path to be resolved before we get to the --remote check - const sqlFile = join(root, 'query.sql'); - writeFileSync(sqlFile, 'SELECT 1'); - + // The rejection fires before the file is read, so the file does not need to exist. await expect( runDbCommand(root, { action: 'execute', file: 'query.sql', remote: true }), ).rejects.toThrow('process.exit:1'); }); + it('rejects --file before prompting for destructive confirmation (ordering regression guard)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + await expect( + runDbCommand(root, { action: 'execute', file: 'delete.sql', remote: true }), + ).rejects.toThrow('process.exit:1'); + + // The confirmation prompt must NOT have fired — the error comes first. + expect(mockConfirm).not.toHaveBeenCalled(); + }); + it('errors on proxy non-OK response', async () => { const root = createProjectRoot(); fetchMock.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })); From de247c3b28101f69a2c24c7db3e55024ac2db742 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:36:40 +0800 Subject: [PATCH 05/13] feat(cli): wire --remote into db studio (PG only) + docs sweep - runStudio now accepts an optional `remote` flag - PG remote: fetches stored connection URL via getDatabaseUrl, passes it to writeDrizzleConfig + drizzle-kit studio (same as local PG path) - D1 remote: errors with a friendly "not yet supported" message pointing users to `void db execute --remote` for ad-hoc queries - help.ts HELP_TREE: updated execute and studio entries to document --remote flag with options and examples - db-args.ts: updated studio subcommand description to mention --remote and the D1 limitation - docs/reference/cli.md: fully documents --remote for both execute and studio (dialects, confirmation prompt, 404 case, D1 limitation) - Tests: db-studio-remote.test.ts covering PG remote happy path, 404, non-404 re-throw, auth/project errors, D1 remote rejection, and local regression guards (9 tests) Co-Authored-By: Claude Sonnet 4.6 --- docs/reference/cli.md | 21 +- packages/void/src/cli/db-args.ts | 5 +- packages/void/src/cli/db.ts | 52 ++- packages/void/src/cli/help.ts | 43 ++- .../void/test/unit/db-studio-remote.test.ts | 305 ++++++++++++++++++ 5 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 packages/void/test/unit/db-studio-remote.test.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6d1984ef8..d9a49b93c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -289,9 +289,17 @@ Programmatic seed modules must export either a default function or a named `seed ``` void db execute void db execute --file +void db execute --remote ``` -Run ad-hoc SQL against the local D1 database. Provide SQL inline or from a file. SELECT queries display results as a formatted table; other statements execute silently. +Run ad-hoc SQL against the database. Provide SQL inline or from a file. SELECT queries display results as a formatted table; other statements execute silently. + +By default, targets the local database. Pass `--remote` to run against the deployed database: + +- **D1 projects**: routes the query through the Void proxy (`proxy.void.cloud/d1/query`) using your auth token. No Cloudflare credentials needed. +- **PostgreSQL projects**: fetches the stored connection string from the platform and connects directly. Requires that `void db set-url` has been run at least once (the platform stores the URL encrypted). If the URL isn't stored yet, you will see: _"Run `void db set-url` once to populate it, then retry."_ + +For destructive statements (`DELETE`, `UPDATE`, `DROP`, etc.) when running in a TTY, you will be prompted to confirm before the query is sent to the deployed database. Non-TTY environments (CI) skip the prompt. ### `void db migrate` @@ -305,7 +313,16 @@ Pass `--remote` to apply pending migrations to the remote database instead. Requ ### `void db studio` -Open [Drizzle Studio](https://orm.drizzle.team/docs/drizzle-kit-studio) for the local database. Launches a web-based GUI for browsing and editing your data. +``` +void db studio [--remote] +``` + +Open [Drizzle Studio](https://orm.drizzle.team/docs/drizzle-kit-studio) for the database. Launches a web-based GUI for browsing and editing your data. + +By default, targets the local database. Pass `--remote` to open Studio against the deployed database: + +- **PostgreSQL projects**: fetches the stored connection string from the platform and opens Studio against it. Requires being logged in (`void auth login`) with a linked project. If the URL isn't stored yet, run `void db set-url` first. +- **D1 projects**: remote Studio is not yet supported. Use `void db execute --remote` for ad-hoc queries against your deployed D1 database. ### `void db rename-migrations` diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 34917859c..f22666e4d 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -24,8 +24,9 @@ export const dbSubcommands: Array = [ }, { name: 'studio', - description: 'Open Drizzle Studio for the local database', - flags: [{ name: '--remote', description: 'Open Studio against the remote database' }], + description: + 'Open Drizzle Studio against the database (use --remote for the deployed PostgreSQL database; D1 remote not yet supported)', + flags: [{ name: '--remote', description: 'Open Studio against the deployed database' }], }, { name: 'set-url', description: 'Update the PostgreSQL connection string for deployment' }, { name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' }, diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 5de339b9b..9b1bb73a7 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -154,7 +154,7 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } else if (args.action === 'migrate') { await runMigrate(root, dialect, args.remote); } else if (args.action === 'studio') { - await runStudio(root, dialect); + await runStudio(root, dialect, args.remote); } else if (args.action === 'set-url') { await runSetUrl(root); } else if (args.action === 'rename-migrations') { @@ -782,7 +782,55 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole outro('Done.'); } -async function runStudio(root: string, dialect: DatabaseDialect): Promise { +async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolean): Promise { + if (remote) { + if (dialect !== 'postgresql') { + log.error( + "Remote D1 studio is not yet supported. Use 'void db execute --remote' for ad-hoc queries against your deployed D1 database.", + ); + process.exit(1); + } + + // PG remote: fetch the connection URL from the platform, then open Studio against it. + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + + const projectConfig = readProjectConfig(root); + if (!projectConfig) { + log.error('No project linked. Run `void deploy` first or link a project.'); + process.exit(1); + } + + const client = new PlatformClient(token, { root }); + let dbUrl: string; + try { + const result = await client.getDatabaseUrl(projectConfig.projectId); + dbUrl = result.url; + } catch (err) { + if (err instanceof PlatformApiError && err.status === 404) { + log.error( + "This project's database URL isn't stored on the platform yet. Run `void db set-url` once to populate it, then retry.", + ); + process.exit(1); + } + throw err; + } + + const { writeDrizzleConfig } = await import('../drizzle/config.ts'); + const configPath = writeDrizzleConfig(root, dbUrl, 'postgresql'); + + log.info('Opening Drizzle Studio (remote)...'); + try { + runDrizzleKit(root, ['studio', '--config', configPath]); + } catch { + process.exit(1); + } + return; + } + if (dialect === 'postgresql') { const { writeDrizzleConfig } = await import('../drizzle/config.ts'); const url = getPgUrl(root); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 5189df64f..276036dc9 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -195,9 +195,12 @@ const HELP_TREE: HelpTree = { { command: 'status', description: 'Show migration status (local and remote)' }, { command: 'reset', description: 'Reset local DB and re-run migrations' }, { command: 'seed', description: 'Reset local DB, run migrations, and seed' }, - { command: 'execute ', description: 'Run SQL against the local database' }, + { + command: 'execute ', + description: 'Run SQL against the database (--remote for deployed)', + }, { command: 'migrate', description: 'Apply pending migrations' }, - { command: 'studio', description: 'Open Drizzle Studio for the local database' }, + { command: 'studio', description: 'Open Drizzle Studio (--remote for deployed PG)' }, { command: 'export', description: 'Export local database as SQL' }, { command: 'set-url', description: 'Update the PostgreSQL connection string' }, { command: 'rename-migrations', description: 'Rename migrations to timestamp format' }, @@ -213,12 +216,24 @@ const HELP_TREE: HelpTree = { options: [{ command: '--file ', description: 'Seed from a specific file' }], }, execute: { - summary: 'Run SQL against the local database.', + summary: + 'Run SQL against the database (local by default; use --remote for the deployed database).', usage: ['void db execute ', 'void db execute --file '], - options: [{ command: '--file ', description: 'Run SQL from a file instead' }], + options: [ + { command: '--file ', description: 'Run SQL from a file instead' }, + { + command: '--remote', + description: + 'Run against the deployed database (D1 via proxy; PostgreSQL via direct connection)', + }, + ], examples: [ { command: 'void db execute "select * from users"', description: 'Run inline SQL' }, { command: 'void db execute --file query.sql', description: 'Run SQL from a file' }, + { + command: 'void db execute --remote "select count(*) from users"', + description: 'Query the deployed database', + }, ], }, migrate: { @@ -226,7 +241,25 @@ const HELP_TREE: HelpTree = { usage: ['void db migrate [options]'], options: [{ command: '--remote', description: 'Apply to the remote database' }], }, - studio: basicSubcommand('Open Drizzle Studio for the local database.', 'void db studio'), + studio: { + summary: + 'Open Drizzle Studio against the database (local by default; use --remote for the deployed PostgreSQL database; D1 remote not yet supported).', + usage: ['void db studio [options]'], + options: [ + { + command: '--remote', + description: + 'Open Studio against the deployed PostgreSQL database (D1 remote not yet supported)', + }, + ], + examples: [ + { command: 'void db studio', description: 'Open Studio for the local database' }, + { + command: 'void db studio --remote', + description: 'Open Studio against the deployed PostgreSQL database', + }, + ], + }, export: { summary: 'Export local database contents as SQL.', usage: ['void db export [options]'], diff --git a/packages/void/test/unit/db-studio-remote.test.ts b/packages/void/test/unit/db-studio-remote.test.ts new file mode 100644 index 000000000..7bc2b20d9 --- /dev/null +++ b/packages/void/test/unit/db-studio-remote.test.ts @@ -0,0 +1,305 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +// ---- hoisted mocks --------------------------------------------------------- + +const mockGetToken = vi.fn<() => string | null>(() => null); +const mockReadProjectConfig = vi.fn<() => { projectId: string; slug: string } | null>(() => null); + +const mockGetDatabaseUrl = vi.fn<() => Promise<{ url: string }>>(); + +vi.mock('../../src/cli/auth', () => ({ + getToken: () => mockGetToken(), + isStagingMode: () => false, +})); + +vi.mock('../../src/cli/project', () => ({ + readProjectConfig: () => mockReadProjectConfig(), + isVoidInternalStaging: () => false, +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + getDatabaseUrl() { + return mockGetDatabaseUrl(); + } + getResources() { + return Promise.resolve([]); + } + }, + PlatformApiError: class extends Error { + status: number; + body: string; + constructor(action: string, status: number, body: string) { + super(`${action}: ${status} ${body}`); + this.status = status; + this.body = body; + } + }, +})); + +// Mock writeDrizzleConfig to avoid needing a real schema on disk. +const mockWriteDrizzleConfig = vi.fn<(root: string, dbPath: string, dialect?: string) => string>( + (root, _dbPath, _dialect) => join(root, '.void', 'drizzle.config.json'), +); + +vi.mock('../../src/drizzle/config.ts', () => ({ + writeDrizzleConfig: (...args: Array) => + mockWriteDrizzleConfig(...(args as Parameters)), +})); + +// Mock execFileSync (used by runDrizzleKit) so no child process is spawned. +const mockExecFileSync = vi.fn<() => void>(() => undefined); + +vi.mock('node:child_process', () => ({ + execFileSync: (...args: Array) => + mockExecFileSync(...(args as Parameters)), +})); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn((v: unknown) => typeof v === 'symbol'), + log: { + error: vi.fn(), + info: vi.fn(), + step: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const roots: Array = []; + +function createProjectRoot(options: { database?: 'pg' } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'void-db-studio-remote-')); + roots.push(root); + + // db/migrations + schema (schema is needed only in the non-mocked local path) + const migrationsDir = join(root, 'db', 'migrations'); + mkdirSync(join(migrationsDir, 'meta'), { recursive: true }); + + const voidConfig = options.database === 'pg' ? { database: 'pg' } : {}; + writeFileSync(join(root, 'void.json'), JSON.stringify(voidConfig)); + + // .void dir + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ projectId: 'proj-abc', slug: 'test-proj' }), + ); + + return root; +} + +// --------------------------------------------------------------------------- +// Suite — PG remote +// --------------------------------------------------------------------------- + +describe('void db studio --remote (PostgreSQL)', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj-abc', slug: 'test-proj' }); + + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('fetches the database URL via getDatabaseUrl and calls writeDrizzleConfig with it', async () => { + const root = createProjectRoot({ database: 'pg' }); + const dbUrl = 'postgres://user:pass@host:5432/mydb'; + mockGetDatabaseUrl.mockResolvedValue({ url: dbUrl }); + + await runDbCommand(root, { action: 'studio', remote: true }); + + expect(mockGetDatabaseUrl).toHaveBeenCalledTimes(1); + expect(mockWriteDrizzleConfig).toHaveBeenCalledWith(root, dbUrl, 'postgresql'); + }); + + it('spawns drizzle-kit studio --config ', async () => { + const root = createProjectRoot({ database: 'pg' }); + const dbUrl = 'postgres://user:pass@host:5432/mydb'; + mockGetDatabaseUrl.mockResolvedValue({ url: dbUrl }); + + await runDbCommand(root, { action: 'studio', remote: true }); + + // execFileSync is called with (node, [drizzleKitBin, 'studio', '--config', configPath], opts) + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const execCallArgs = mockExecFileSync.mock.calls[0] as unknown as [ + string, + Array, + unknown, + ]; + const cliArgs = execCallArgs[1]; + expect(cliArgs).toContain('studio'); + expect(cliArgs).toContain('--config'); + }); + + it('surfaces the friendly 404 message when the URL is not stored', async () => { + const root = createProjectRoot({ database: 'pg' }); + + const { PlatformApiError } = await import('../../src/cli/client.ts'); + mockGetDatabaseUrl.mockRejectedValue(new PlatformApiError('test', 404, '{}')); + + const { log: clackLog } = await import('@clack/prompts'); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + + const errorCalls = (clackLog.error as ReturnType).mock.calls + .map((c: Array) => String(c[0])) + .join('\n'); + expect(errorCalls).toContain('void db set-url'); + // writeDrizzleConfig and execFileSync must NOT have been called + expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('re-throws non-404 errors from getDatabaseUrl', async () => { + const root = createProjectRoot({ database: 'pg' }); + + const { PlatformApiError } = await import('../../src/cli/client.ts'); + mockGetDatabaseUrl.mockRejectedValue(new PlatformApiError('test', 403, '{}')); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + PlatformApiError, + ); + }); + + it('errors when not logged in', async () => { + const root = createProjectRoot({ database: 'pg' }); + mockGetToken.mockReturnValue(null); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('errors when no project is linked', async () => { + const root = createProjectRoot({ database: 'pg' }); + mockReadProjectConfig.mockReturnValue(null); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Suite — D1 remote (not yet supported) +// --------------------------------------------------------------------------- + +describe('void db studio --remote (D1)', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj-abc', slug: 'test-proj' }); + + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('errors with "not yet supported" message and does not call writeDrizzleConfig or execFileSync', async () => { + const root = createProjectRoot(); // D1 project (no database: 'pg') + + const { log: clackLog } = await import('@clack/prompts'); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + + const errorCalls = (clackLog.error as ReturnType).mock.calls + .map((c: Array) => String(c[0])) + .join('\n'); + expect(errorCalls).toContain('not yet supported'); + expect(errorCalls).toContain('void db execute --remote'); + + expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Suite — local (regression: --remote unset) +// --------------------------------------------------------------------------- + +describe('void db studio (local, regression)', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('local D1 studio fails (no local DB) without calling getDatabaseUrl', async () => { + const root = createProjectRoot(); // D1, no local DB file + + // The local D1 path checks for the DB file and exits. Confirm that: + // - getDatabaseUrl is NOT called (it's remote-only) + await expect(runDbCommand(root, { action: 'studio' })).rejects.toThrow('process.exit:1'); + + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + }); + + it('local PG studio does not call getDatabaseUrl (uses local URL path)', async () => { + const root = createProjectRoot({ database: 'pg' }); + + // The local PG path calls getPgUrl (reads .env.local). No DATABASE_URL set, so it throws. + // Regardless, getDatabaseUrl must NOT be called — it's only used for --remote. + await expect(runDbCommand(root, { action: 'studio' })).rejects.toThrow(); + + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + }); +}); From f116a7a1e1124ae970440725241bb059a559510e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:39:04 +0800 Subject: [PATCH 06/13] fix(test): tighten types in db-execute-remote tests Type `mockConfirm` with its actual parameter shape so the clack mock wrapper can forward the argument without a variadic spread, and so that `mock.calls[0][0]` resolves to `{ message: string }` instead of the out-of-bounds `undefined` that the empty-tuple signature produced. Co-Authored-By: Claude Sonnet 4.6 --- packages/void/test/unit/db-execute-remote.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index c79d027ee..961b0ffd9 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -61,13 +61,13 @@ vi.mock('pg', () => ({ const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch | undefined; -const mockConfirm = vi.fn<() => Promise>(); +const mockConfirm = vi.fn<(opts: { message: string }) => Promise>(); const mockIsCancel = vi.fn<(value: unknown) => boolean>((v) => typeof v === 'symbol'); vi.mock('@clack/prompts', () => ({ intro: vi.fn(), outro: vi.fn(), - confirm: (...args: Array) => mockConfirm(...args), + confirm: (opts: { message: string }) => mockConfirm(opts), isCancel: (v: unknown) => mockIsCancel(v), log: { error: vi.fn(), From 37645f28422f0552bce6b772886f7a8e65de3808 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 18:45:38 +0800 Subject: [PATCH 07/13] polish(cli): tighten db remote descriptions and quote conventions - Fix cheat sheet: update `void db studio` row description; add missing `void db execute` row - Shorten studio one-liner: drop "D1 remote not yet supported" caveat (belongs in HELP_TREE and the error message) - Use backticks for \`void db execute --remote\` command reference in D1 studio error Co-Authored-By: Claude Sonnet 4.6 --- docs/reference/cli.md | 53 ++++++++++++++++---------------- packages/void/src/cli/db-args.ts | 2 +- packages/void/src/cli/db.ts | 2 +- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d9a49b93c..912025d57 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -10,32 +10,33 @@ Use this page as a command reference. If you are setting up a project for the fi ## Cheat Sheet -| Command | Purpose | -| --------------------------------- | ------------------------------------------------ | -| `void deploy` | Build and deploy to Void | -| `void prepare` | Generate `.void` artifacts without starting Vite | -| `void gen model [cols...]` | Scaffold migration + CRUD routes | -| `void gen route ` | Create an API route | -| `void db push` | Apply schema directly without migration files | -| `void db generate` | Generate SQL migrations from schema changes | -| `void db status` | Show local/remote migration status | -| `void db reset` | Drop and re-apply all migrations | -| `void db seed` | Reset + seed local database | -| `void db studio` | Open Drizzle Studio for local database | -| `void secret put ` | Set a production secret | -| `void secret list` | List production secrets | -| `void secret sync .env.local` | Bulk upload secrets from dotenv file | -| `void env check [--remote]` | Validate env.ts schema | -| `void env types` | Regenerate .void/env.d.ts from env.ts | -| `void env example` | Refresh the void-managed block in .env.example | -| `void auth login` | Authenticate with Void | -| `void project link` | Link directory to a project | -| `void project logs` | Show runtime logs from deployed project | -| `void project rollback` | Roll back to a previous deployment | -| `void project cancel` | Cancel an active deployment | -| `void project purge-cache` | Purge all cached pages | -| `void mcp` | Start the Void MCP server | -| `void init` | Setup wizard for new or existing projects | +| Command | Purpose | +| --------------------------------- | ------------------------------------------------------------------- | +| `void deploy` | Build and deploy to Void | +| `void prepare` | Generate `.void` artifacts without starting Vite | +| `void gen model [cols...]` | Scaffold migration + CRUD routes | +| `void gen route ` | Create an API route | +| `void db push` | Apply schema directly without migration files | +| `void db generate` | Generate SQL migrations from schema changes | +| `void db status` | Show local/remote migration status | +| `void db reset` | Drop and re-apply all migrations | +| `void db seed` | Reset + seed local database | +| `void db execute ` | Run SQL against the database (--remote for deployed) | +| `void db studio` | Open Drizzle Studio (--remote for the deployed PostgreSQL database) | +| `void secret put ` | Set a production secret | +| `void secret list` | List production secrets | +| `void secret sync .env.local` | Bulk upload secrets from dotenv file | +| `void env check [--remote]` | Validate env.ts schema | +| `void env types` | Regenerate .void/env.d.ts from env.ts | +| `void env example` | Refresh the void-managed block in .env.example | +| `void auth login` | Authenticate with Void | +| `void project link` | Link directory to a project | +| `void project logs` | Show runtime logs from deployed project | +| `void project rollback` | Roll back to a previous deployment | +| `void project cancel` | Cancel an active deployment | +| `void project purge-cache` | Purge all cached pages | +| `void mcp` | Start the Void MCP server | +| `void init` | Setup wizard for new or existing projects | ## Binary Invocation diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index f22666e4d..c57a9fe3c 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -25,7 +25,7 @@ export const dbSubcommands: Array = [ { name: 'studio', description: - 'Open Drizzle Studio against the database (use --remote for the deployed PostgreSQL database; D1 remote not yet supported)', + 'Open Drizzle Studio against the database (use --remote for the deployed PostgreSQL database)', flags: [{ name: '--remote', description: 'Open Studio against the deployed database' }], }, { name: 'set-url', description: 'Update the PostgreSQL connection string for deployment' }, diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 9b1bb73a7..cb8290018 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -786,7 +786,7 @@ async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolea if (remote) { if (dialect !== 'postgresql') { log.error( - "Remote D1 studio is not yet supported. Use 'void db execute --remote' for ad-hoc queries against your deployed D1 database.", + 'Remote D1 studio is not yet supported. Use `void db execute --remote` for ad-hoc queries against your deployed D1 database.', ); process.exit(1); } From d0bfa8368cc450a0f05a6fb07de447af87e3ba8f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 19:29:04 +0800 Subject: [PATCH 08/13] fix(cli): tighten remote-db safety prompt and reject --remote on export - Remove WITH from isSafeStatement safe-list (writable CTEs are indistinguishable without a real parser; fail-closed by prompting for all WITH queries) - Reject multi-statement SQL: strip trailing semicolon then treat any remaining internal `;` as unsafe - Reject `void db export --remote` with an actionable error before it silently falls through to the local export path - Update WITH CTE test to expect the prompt to fire (semantic change: now unsafe) - Add db-args regression tests for export --remote rejection Co-Authored-By: Claude Sonnet 4.6 --- packages/void/src/cli/db-args.ts | 3 +++ packages/void/src/cli/db.ts | 22 ++++++++++++++++--- packages/void/test/unit/db-args.test.ts | 12 ++++++++++ .../void/test/unit/db-execute-remote.test.ts | 5 +++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index c57a9fe3c..152141c3d 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -161,6 +161,9 @@ export function parseDbArgs(args: Array): DbArgs { } if (action === 'export') { + if (parsed.values.remote === true) { + throw new Error('`void db export --remote` is not yet supported. Local export only for now.'); + } if (rest.length > 0) { throw new Error(`db: Unexpected argument '${rest[0]}'.`); } diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index cb8290018..3375a2de8 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -408,12 +408,28 @@ async function runSeed( } /** - * Returns true when the SQL is read-only (SELECT / EXPLAIN / WITH). + * Returns true when the SQL is read-only (SELECT / EXPLAIN only). * Used to skip the destructive-statement safety prompt for safe queries. - * // Note: leading SQL comments are not stripped; `-- foo\nSELECT 1` will be flagged as destructive. This is a UX nuisance, not a safety hole. + * + * This is a fail-closed heuristic, not a real SQL parser: + * - `WITH` (CTEs) is intentionally excluded: writable CTEs such as + * `WITH deleted AS (DELETE FROM users RETURNING *) SELECT * FROM deleted` + * are destructive but match a naive `WITH` prefix check. Users with + * read-only CTEs will see the safety prompt — minor friction, safe by default. + * - Multi-statement SQL (`SELECT 1; DELETE FROM users`) is rejected: any `;` + * followed by non-whitespace content after stripping a trailing semicolon is + * treated as unsafe. + * - Leading SQL comments are not stripped; `-- foo\nSELECT 1` will be flagged + * as destructive. This is a UX nuisance, not a safety hole. */ function isSafeStatement(sql: string): boolean { - return /^\s*(SELECT|EXPLAIN|WITH)\b/i.test(sql); + // Strip a single trailing semicolon/whitespace, then bail if any internal `;` remains. + const stripped = sql.replace(/;\s*$/, ''); + if (stripped.includes(';')) { + return false; + } + // Only SELECT/EXPLAIN prefixes are reliably safe without a parser. + return /^\s*(SELECT|EXPLAIN)\b/i.test(stripped); } /** diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 803da1ee4..e0905a396 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -157,6 +157,18 @@ describe('parseDbArgs', () => { expect(() => parseDbArgs(['export', 'extra'])).toThrow("db: Unexpected argument 'extra'."); }); + it('errors on export with --remote (not yet supported — must not silently fall through to local export)', () => { + expect(() => parseDbArgs(['export', '--remote'])).toThrow( + '`void db export --remote` is not yet supported. Local export only for now.', + ); + }); + + it('errors on export with --remote combined with other flags', () => { + expect(() => parseDbArgs(['export', '--remote', '--output', 'dump.sql'])).toThrow( + '`void db export --remote` is not yet supported. Local export only for now.', + ); + }); + it('parses studio', () => { expect(parseDbArgs(['studio'])).toEqual({ action: 'studio', remote: undefined }); }); diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index 961b0ffd9..83e47d89f 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -319,9 +319,10 @@ describe('void db execute --remote (D1)', () => { expect(mockConfirm).not.toHaveBeenCalled(); }); - it('skips confirmation for WITH (CTE) statements', async () => { + it('prompts for confirmation for WITH (CTE) statements (fail-closed: writable CTEs are indistinguishable without a parser)', async () => { const root = createProjectRoot(); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); await runDbCommand(root, { @@ -330,7 +331,7 @@ describe('void db execute --remote (D1)', () => { remote: true, }); - expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockConfirm).toHaveBeenCalledTimes(1); }); it('includes CF Access service-token headers when env vars are set', async () => { From 276ed57be7714c7267a6412f387ed70d448a4618 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 19:56:30 +0800 Subject: [PATCH 09/13] fix(cli): honor configured D1 binding as fallback and reject EXPLAIN ANALYZE as safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `isSafeStatement`: treat `EXPLAIN ANALYZE` as destructive — in PostgreSQL it actually executes the underlying DML to collect runtime statistics, so the previous `EXPLAIN` prefix match was a safety hole. Only plain `EXPLAIN` (without `ANALYZE`) is now allowed through. - D1 remote execute: when `getResources()` fails or returns no D1 resource, fall back to the binding name configured in `void.json` (`inference.bindings.db`) instead of always hardcoding `'DB'`. Projects with a custom D1 binding name no longer silently send the wrong binding. Co-Authored-By: Claude Sonnet 4.6 --- packages/void/src/cli/db.ts | 31 ++++++++++++++--- .../void/test/unit/db-execute-remote.test.ts | 33 +++++++++++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 3375a2de8..8b16a2f58 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -3,7 +3,12 @@ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSyn import { fileURLToPath, pathToFileURL } from 'node:url'; import { confirm, intro, isCancel, outro, log } from '@clack/prompts'; import { extname, join, relative, resolve } from 'pathe'; -import { readConfig, getDatabaseDialect, type DatabaseDialect } from '../config.ts'; +import { + readConfig, + getDatabaseDialect, + resolveBindingNames, + type DatabaseDialect, +} from '../config.ts'; import { collectMigrations, readJournal, @@ -421,6 +426,10 @@ async function runSeed( * treated as unsafe. * - Leading SQL comments are not stripped; `-- foo\nSELECT 1` will be flagged * as destructive. This is a UX nuisance, not a safety hole. + * - `EXPLAIN ANALYZE` is explicitly excluded: in PostgreSQL, `EXPLAIN ANALYZE` + * actually executes the underlying statement (including DML) to collect runtime + * statistics. A naive `EXPLAIN` prefix check would treat it as safe when it is + * in fact destructive. Only plain `EXPLAIN` (without `ANALYZE`) is allowed. */ function isSafeStatement(sql: string): boolean { // Strip a single trailing semicolon/whitespace, then bail if any internal `;` remains. @@ -428,8 +437,15 @@ function isSafeStatement(sql: string): boolean { if (stripped.includes(';')) { return false; } - // Only SELECT/EXPLAIN prefixes are reliably safe without a parser. - return /^\s*(SELECT|EXPLAIN)\b/i.test(stripped); + // SELECT is always safe. + if (/^\s*SELECT\b/i.test(stripped)) { + return true; + } + // EXPLAIN is safe only when not followed by ANALYZE (which executes the statement). + if (/^\s*EXPLAIN\b/i.test(stripped) && !/^\s*EXPLAIN\s+ANALYZE\b/i.test(stripped)) { + return true; + } + return false; } /** @@ -525,7 +541,12 @@ async function runExecute( // D1 remote: route through the platform proxy. const platformClient = new PlatformClient(token, { root }); - let binding = 'DB'; + // Resolve the locally-configured binding name (inference.bindings.db in void.json) so that + // the fallback honours custom names instead of always hardcoding 'DB'. + const voidConfig = readConfig(root); + const configuredBindingNames = resolveBindingNames(voidConfig.inference?.bindings); + const configuredD1Binding = configuredBindingNames?.d1 ?? 'DB'; + let binding = configuredD1Binding; try { const resources = await platformClient.getResources(projectConfig.projectId); // TODO: support --binding for projects with multiple D1 bindings (currently auto-selects the first). @@ -534,7 +555,7 @@ async function runExecute( binding = d1Resource.binding_name; } } catch { - // Fall back to default binding 'DB' if resource discovery fails + // Fall back to the locally-configured binding name (or 'DB' by default) if resource discovery fails. } const proxyUrl = isStagingMode(root) ? STAGING_PROXY_URL : DEFAULT_PROXY_URL; diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index 83e47d89f..a3197fa2f 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -84,14 +84,17 @@ vi.mock('@clack/prompts', () => ({ const roots: Array = []; -function createProjectRoot(options: { database?: 'pg' } = {}): string { +function createProjectRoot(options: { database?: 'pg'; d1Binding?: string } = {}): string { const root = mkdtempSync(join(tmpdir(), 'void-db-execute-remote-')); roots.push(root); const migrationsDir = join(root, 'db', 'migrations'); mkdirSync(join(migrationsDir, 'meta'), { recursive: true }); - const voidConfig = options.database === 'pg' ? { database: 'pg' } : {}; + const voidConfig: Record = options.database === 'pg' ? { database: 'pg' } : {}; + if (options.d1Binding) { + voidConfig.inference = { bindings: { db: options.d1Binding } }; + } writeFileSync(join(root, 'void.json'), JSON.stringify(voidConfig)); // .void/project.json to satisfy readProjectConfig (the REAL one is mocked, @@ -206,6 +209,17 @@ describe('void db execute --remote (D1)', () => { expect(body.binding).toBe('DB'); }); + it('uses the configured d1 binding from void.json as fallback when getResources throws', async () => { + const root = createProjectRoot({ d1Binding: 'MY_CUSTOM_DB' }); + mockGetResources.mockRejectedValue(new Error('network error')); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { action: 'execute', command: 'SELECT 1', remote: true }); + + const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.binding).toBe('MY_CUSTOM_DB'); + }); + it('renders a table when results are returned', async () => { const root = createProjectRoot(); const rows = [{ id: 1, name: 'Alice' }]; @@ -319,6 +333,21 @@ describe('void db execute --remote (D1)', () => { expect(mockConfirm).not.toHaveBeenCalled(); }); + it('prompts for confirmation for EXPLAIN ANALYZE (executes DML in PostgreSQL)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN ANALYZE DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + it('prompts for confirmation for WITH (CTE) statements (fail-closed: writable CTEs are indistinguishable without a parser)', async () => { const root = createProjectRoot(); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); From 24947178b1e54c591aad5a4814e072ca5326a6ec Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 20:10:43 +0800 Subject: [PATCH 10/13] test(cli): update db execute help-text expectation to match new description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `void db execute` description was updated in this PR from 'Run SQL against the local database' to 'Run SQL against the database (local by default; …)'; update the integration test assertion to match the new wording. Co-Authored-By: Claude Sonnet 4.6 --- packages/void/test/integration/cli-help.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/void/test/integration/cli-help.test.ts b/packages/void/test/integration/cli-help.test.ts index dc7e9d4bc..13532d366 100644 --- a/packages/void/test/integration/cli-help.test.ts +++ b/packages/void/test/integration/cli-help.test.ts @@ -73,7 +73,7 @@ describe('void help (subprocess)', () => { const { output, status } = runVoid(root, ['db', 'help', 'execute']); expect(status).toBe(0); - expect(output).toContain('Run SQL against the local database'); + expect(output).toContain('Run SQL against the database'); expect(output).toContain('void db execute --file '); }); From 3b129732452164d56fc85004430e1fe2a1a2eca7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 27 May 2026 20:10:59 +0800 Subject: [PATCH 11/13] fix(cli): add confirmExisting guard to link command and add link.test.ts Forward-port the `confirmExisting` UX (prompt before overwriting an existing deployment) from main into this branch so the test file matches the expected behavior. Add `packages/void/test/unit/link.test.ts` with a `picocolors` no-op mock so that assertions on the `outro` message work on CI where `FORCE_COLOR` makes `pc.blue()` emit ANSI escape codes. Co-Authored-By: Claude Sonnet 4.6 --- packages/void/src/cli/link.ts | 13 +-- packages/void/src/cli/prompt.ts | 15 +++- packages/void/test/unit/link.test.ts | 117 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 packages/void/test/unit/link.test.ts diff --git a/packages/void/src/cli/link.ts b/packages/void/src/cli/link.ts index 6a0f2747d..6519f92a2 100644 --- a/packages/void/src/cli/link.ts +++ b/packages/void/src/cli/link.ts @@ -5,6 +5,7 @@ import { promptAndCreateProject } from './create-project.ts'; import { cliTitle, pc } from './output.ts'; import { readProjectConfig, writeProjectConfig } from './project.ts'; import { promptProjectSelection } from './prompt.ts'; +import { resolveProjectBySlug } from './resolve-project.ts'; export async function runLink(root: string, slug?: string): Promise { console.log(); @@ -30,18 +31,20 @@ export async function runLink(root: string, slug?: string): Promise { if (slug) { // Non-interactive: link by slug - const project = projects.find((p) => p.slug === slug); - if (!project) { + const config = resolveProjectBySlug(projects, slug); + if (!config) { cancel(`project: No project found with slug '${slug}'.`); process.exit(1); } - writeProjectConfig(root, { projectId: project.id, slug: project.slug }); - outro(`Linked to ${pc.blue(project.slug)}.${getSiteDomain(root)}`); + writeProjectConfig(root, config); + outro(`Linked to ${pc.blue(config.slug)}.${getSiteDomain(root)}`); return; } // Interactive: prompt user to select or create - const result = await promptProjectSelection(projects, 'Select a project:', root); + const result = await promptProjectSelection(projects, 'Select a project:', root, { + confirmExisting: true, + }); if (result.action === 'create') { const config = await promptAndCreateProject(root, client); diff --git a/packages/void/src/cli/prompt.ts b/packages/void/src/cli/prompt.ts index 60010cd3c..2a8eaddc7 100644 --- a/packages/void/src/cli/prompt.ts +++ b/packages/void/src/cli/prompt.ts @@ -1,4 +1,4 @@ -import { select, text, isCancel, cancel } from '@clack/prompts'; +import { select, text, confirm, isCancel, cancel } from '@clack/prompts'; import { getSiteDomain } from './auth.ts'; import type { ProjectResponse } from './client.ts'; import { validateProjectSlug } from './project-slug.ts'; @@ -41,7 +41,7 @@ export async function promptProjectSelection( projects: Array, message = 'Select a project:', root: string = process.cwd(), - options: { includeCreate?: boolean } = {}, + options: { confirmExisting?: boolean; includeCreate?: boolean } = {}, ): Promise { const choices = [ ...(options.includeCreate === false @@ -68,6 +68,17 @@ export async function promptProjectSelection( } const project = projects.find((p) => p.id === result)!; + if (options.confirmExisting) { + const confirmed = await confirm({ + message: + `Linking to an existing project. Running \`void deploy\` will overwrite the current deployment of ` + + `${project.slug}.${getSiteDomain(root)}. Continue?`, + }); + if (isCancel(confirmed) || !confirmed) { + cancel('Project link cancelled.'); + process.exit(0); + } + } return { action: 'select', project }; } diff --git a/packages/void/test/unit/link.test.ts b/packages/void/test/unit/link.test.ts new file mode 100644 index 000000000..f608c2554 --- /dev/null +++ b/packages/void/test/unit/link.test.ts @@ -0,0 +1,117 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +const mockCancel = vi.fn(); +const mockConfirm = vi.fn(); +const mockGetToken = vi.fn(); +const mockListProjects = vi.fn(); +const mockOutro = vi.fn(); +const mockSelect = vi.fn(); + +const existingProject = { + id: 'proj_1', + user_id: 'user_1', + slug: 'existing-app', + type: 'worker', + created_at: '2026-05-27T00:00:00.000Z', + custom_domain: null, +}; + +vi.mock('@clack/prompts', () => ({ + cancel: (...args: Array) => mockCancel(...args), + confirm: (...args: Array) => mockConfirm(...args), + intro: vi.fn(), + isCancel: (value: unknown) => value === Symbol.for('cancel'), + log: { warn: vi.fn() }, + outro: (...args: Array) => mockOutro(...args), + select: (...args: Array) => mockSelect(...args), +})); + +vi.mock('../../src/cli/auth', () => ({ + getSiteDomain: () => 'void.app', + getToken: (...args: Array) => mockGetToken(...args), +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + listProjects(...args: Array) { + return mockListProjects(...args); + } + }, +})); + +// Disable colors so assertions can match plain strings without ANSI escape codes. +vi.mock('picocolors', () => { + const id = (s: string) => s; + return { + default: { + blue: id, + bold: id, + cyan: id, + dim: id, + gray: id, + green: id, + red: id, + reset: id, + white: id, + yellow: id, + }, + }; +}); + +describe('runLink', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'void-link-')); + mockCancel.mockReset(); + mockConfirm.mockReset(); + mockGetToken.mockReset(); + mockListProjects.mockReset(); + mockOutro.mockReset(); + mockSelect.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + }); + + it('confirms before interactively linking an existing project', async () => { + const { runLink } = await import('../../src/cli/link.ts'); + mockGetToken.mockReturnValue('token_123'); + mockListProjects.mockResolvedValueOnce([existingProject]); + mockSelect.mockResolvedValueOnce('proj_1'); + mockConfirm.mockResolvedValueOnce(true); + + await runLink(root); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: + 'Linking to an existing project. Running `void deploy` will overwrite the current deployment of existing-app.void.app. Continue?', + }); + expect(JSON.parse(readFileSync(join(root, '.void', 'project.json'), 'utf-8'))).toEqual({ + projectId: 'proj_1', + slug: 'existing-app', + }); + expect(mockOutro).toHaveBeenCalledWith('Linked to existing-app.void.app'); + }); + + it('does not write project config when the existing-project guard is declined', async () => { + const { runLink } = await import('../../src/cli/link.ts'); + mockGetToken.mockReturnValue('token_123'); + mockListProjects.mockResolvedValueOnce([existingProject]); + mockSelect.mockResolvedValueOnce('proj_1'); + mockConfirm.mockResolvedValueOnce(false); + vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new Error(`exit ${code}`); + }) as never); + + await expect(runLink(root)).rejects.toThrow('exit 0'); + + expect(mockCancel).toHaveBeenCalledWith('Project link cancelled.'); + expect(existsSync(join(root, '.void', 'project.json'))).toBe(false); + }); +}); From acf7c5cd69412a455ec3863ebe05f67ab79b0eb0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 28 May 2026 09:22:20 +0800 Subject: [PATCH 12/13] fix(cli): block parenthesized EXPLAIN ANALYZE and dedupe remote db url fetch isSafeStatement now also scans the PostgreSQL parenthesized option list (EXPLAIN (ANALYZE) / EXPLAIN (ANALYZE TRUE) / EXPLAIN (ANALYZE FALSE)) for ANALYZE and treats those forms as destructive, alongside the existing bare EXPLAIN ANALYZE check. Fail-closed: even ANALYZE FALSE is rejected since we don't parse arbitrary option expressions. Extract fetchRemoteDatabaseUrl helper so runExecute and runStudio share a single getDatabaseUrl call site with the same 404-to-friendly-error path. --- packages/void/src/cli/db.ts | 68 +++++++++++-------- .../void/test/unit/db-execute-remote.test.ts | 59 ++++++++++++++++ 2 files changed, 99 insertions(+), 28 deletions(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 8b16a2f58..fd215cc5f 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -430,6 +430,12 @@ async function runSeed( * actually executes the underlying statement (including DML) to collect runtime * statistics. A naive `EXPLAIN` prefix check would treat it as safe when it is * in fact destructive. Only plain `EXPLAIN` (without `ANALYZE`) is allowed. + * - `EXPLAIN (...)` parenthesized option lists (PostgreSQL) are scanned for + * `ANALYZE`. The bare-keyword form `EXPLAIN ANALYZE ...` and the option-list + * form `EXPLAIN (ANALYZE) ...` / `EXPLAIN (ANALYZE TRUE) ...` both execute + * the underlying statement, so either shape is rejected. Even + * `EXPLAIN (ANALYZE FALSE)` is rejected (over-strict, but safer than + * guessing the boolean value of an arbitrary option expression). */ function isSafeStatement(sql: string): boolean { // Strip a single trailing semicolon/whitespace, then bail if any internal `;` remains. @@ -441,13 +447,43 @@ function isSafeStatement(sql: string): boolean { if (/^\s*SELECT\b/i.test(stripped)) { return true; } - // EXPLAIN is safe only when not followed by ANALYZE (which executes the statement). - if (/^\s*EXPLAIN\b/i.test(stripped) && !/^\s*EXPLAIN\s+ANALYZE\b/i.test(stripped)) { + // EXPLAIN is safe only when ANALYZE is not in the picture. PostgreSQL accepts + // two shapes: `EXPLAIN ANALYZE ...` (bare keyword) and `EXPLAIN (option, ...) + // ...` (parenthesized option list). Both forms with ANALYZE actually execute + // the underlying statement, so reject either shape. + if (/^\s*EXPLAIN\b/i.test(stripped)) { + if (/^\s*EXPLAIN\s+ANALYZE\b/i.test(stripped)) { + return false; + } + const parenMatch = stripped.match(/^\s*EXPLAIN\s*\(([^)]*)\)/i); + if (parenMatch && /\bANALYZE\b/i.test(parenMatch[1])) { + return false; + } return true; } return false; } +/** + * Fetch a project's remote PostgreSQL connection string from the platform. + * Exits with a friendly message when the URL has never been stored (404); + * other errors propagate so the caller's top-level handler can report them. + */ +async function fetchRemoteDatabaseUrl(client: PlatformClient, projectId: string): Promise { + try { + const result = await client.getDatabaseUrl(projectId); + return result.url; + } catch (err) { + if (err instanceof PlatformApiError && err.status === 404) { + log.error( + "This project's database URL isn't stored on the platform yet. Run `void db set-url` once to populate it, then retry.", + ); + process.exit(1); + } + throw err; + } +} + /** * Prompt the user to confirm a destructive remote query when running in a TTY. * Returns true if execution should proceed, false if it was cancelled. @@ -508,19 +544,7 @@ async function runExecute( if (dialect === 'postgresql') { // PG remote: fetch the connection string from the platform, then connect directly. const client = new PlatformClient(token, { root }); - let dbUrl: string; - try { - const result = await client.getDatabaseUrl(projectConfig.projectId); - dbUrl = result.url; - } catch (err) { - if (err instanceof PlatformApiError && err.status === 404) { - log.error( - "This project's database URL isn't stored on the platform yet. Run `void db set-url` once to populate it, then retry.", - ); - process.exit(1); - } - throw err; - } + const dbUrl = await fetchRemoteDatabaseUrl(client, projectConfig.projectId); const { Client } = await import('pg'); const pgClient: PgClient = new Client({ connectionString: dbUrl }); @@ -842,19 +866,7 @@ async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolea } const client = new PlatformClient(token, { root }); - let dbUrl: string; - try { - const result = await client.getDatabaseUrl(projectConfig.projectId); - dbUrl = result.url; - } catch (err) { - if (err instanceof PlatformApiError && err.status === 404) { - log.error( - "This project's database URL isn't stored on the platform yet. Run `void db set-url` once to populate it, then retry.", - ); - process.exit(1); - } - throw err; - } + const dbUrl = await fetchRemoteDatabaseUrl(client, projectConfig.projectId); const { writeDrizzleConfig } = await import('../drizzle/config.ts'); const configPath = writeDrizzleConfig(root, dbUrl, 'postgresql'); diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index a3197fa2f..0b8c1f14e 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -348,6 +348,65 @@ describe('void db execute --remote (D1)', () => { expect(mockConfirm).toHaveBeenCalledTimes(1); }); + it('prompts for confirmation for EXPLAIN (ANALYZE) DELETE (parenthesized option form)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN (ANALYZE) DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('prompts for confirmation for EXPLAIN (ANALYZE TRUE) UPDATE (option with boolean argument)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN (ANALYZE TRUE) UPDATE users SET name = $1', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('prompts for confirmation for EXPLAIN (ANALYZE FALSE) (fail-closed: rejects even when ANALYZE is disabled)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN (ANALYZE FALSE) SELECT 1', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('skips confirmation for EXPLAIN (VERBOSE) SELECT (option list without ANALYZE is safe)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN (VERBOSE) SELECT 1', + remote: true, + }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + it('prompts for confirmation for WITH (CTE) statements (fail-closed: writable CTEs are indistinguishable without a parser)', async () => { const root = createProjectRoot(); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); From ad6ffbf69c2ae1f60e447be19f4b8afc090d13bc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 28 May 2026 10:55:24 +0800 Subject: [PATCH 13/13] fix(cli): strip SQL comments before EXPLAIN ANALYZE safety check PostgreSQL treats /* */ and -- ... comments as whitespace before parsing, so EXPLAIN /*x*/ ANALYZE DELETE FROM users (or with a line comment, or a leading block comment) is equivalent to EXPLAIN ANALYZE and executes the DELETE. The previous regex required literal whitespace between EXPLAIN and ANALYZE, so these commented variants bypassed the destructive prompt. isSafeStatement now strips block and line comments before applying the existing keyword checks. Side benefit: leading comments on safe queries (e.g. -- header\nSELECT 1) are now correctly classified as safe, resolving the previously-documented UX nuisance. Naive comment stripping doesn't understand string literals; documented in the JSDoc but harmless for fail-closed classification. --- packages/void/src/cli/db.ts | 21 +++--- .../void/test/unit/db-execute-remote.test.ts | 74 +++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index fd215cc5f..6a601280c 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -417,6 +417,16 @@ async function runSeed( * Used to skip the destructive-statement safety prompt for safe queries. * * This is a fail-closed heuristic, not a real SQL parser: + * - SQL comments (slash-star block and double-dash line) are stripped first, + * so commented variants like `EXPLAIN ANALYZE DELETE FROM + * users` or `EXPLAIN -- c\n ANALYZE ...` can't bypass the EXPLAIN ANALYZE + * check by hiding the `ANALYZE` keyword behind a comment that PostgreSQL + * treats as whitespace. This also handles leading comments, so + * `-- header\nSELECT 1` is now correctly classified as safe. + * - The comment-stripping regex is naive and doesn't understand string + * literals: a string literal containing a block-comment marker will have its + * contents stripped. That's harmless here (still classified as SELECT, still + * safe), but worth knowing if this helper is ever reused. * - `WITH` (CTEs) is intentionally excluded: writable CTEs such as * `WITH deleted AS (DELETE FROM users RETURNING *) SELECT * FROM deleted` * are destructive but match a naive `WITH` prefix check. Users with @@ -424,8 +434,6 @@ async function runSeed( * - Multi-statement SQL (`SELECT 1; DELETE FROM users`) is rejected: any `;` * followed by non-whitespace content after stripping a trailing semicolon is * treated as unsafe. - * - Leading SQL comments are not stripped; `-- foo\nSELECT 1` will be flagged - * as destructive. This is a UX nuisance, not a safety hole. * - `EXPLAIN ANALYZE` is explicitly excluded: in PostgreSQL, `EXPLAIN ANALYZE` * actually executes the underlying statement (including DML) to collect runtime * statistics. A naive `EXPLAIN` prefix check would treat it as safe when it is @@ -438,19 +446,14 @@ async function runSeed( * guessing the boolean value of an arbitrary option expression). */ function isSafeStatement(sql: string): boolean { - // Strip a single trailing semicolon/whitespace, then bail if any internal `;` remains. - const stripped = sql.replace(/;\s*$/, ''); + const decommented = sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' '); + const stripped = decommented.replace(/;\s*$/, ''); if (stripped.includes(';')) { return false; } - // SELECT is always safe. if (/^\s*SELECT\b/i.test(stripped)) { return true; } - // EXPLAIN is safe only when ANALYZE is not in the picture. PostgreSQL accepts - // two shapes: `EXPLAIN ANALYZE ...` (bare keyword) and `EXPLAIN (option, ...) - // ...` (parenthesized option list). Both forms with ANALYZE actually execute - // the underlying statement, so reject either shape. if (/^\s*EXPLAIN\b/i.test(stripped)) { if (/^\s*EXPLAIN\s+ANALYZE\b/i.test(stripped)) { return false; diff --git a/packages/void/test/unit/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts index 0b8c1f14e..a63efee38 100644 --- a/packages/void/test/unit/db-execute-remote.test.ts +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -407,6 +407,80 @@ describe('void db execute --remote (D1)', () => { expect(mockConfirm).not.toHaveBeenCalled(); }); + it('prompts for confirmation for EXPLAIN /*x*/ ANALYZE (block comment between keywords, PostgreSQL treats it as whitespace)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN /*x*/ ANALYZE DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('prompts for confirmation for EXPLAIN -- comment\\n ANALYZE (line comment between keywords)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN -- comment\n ANALYZE DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('prompts for confirmation for EXPLAIN /*opts*/ (ANALYZE) DELETE (block comment before parenthesized option list)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: 'EXPLAIN /*opts*/ (ANALYZE) DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('prompts for confirmation for /* leading */ EXPLAIN ANALYZE (leading block comment before EXPLAIN)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + mockConfirm.mockResolvedValue(true); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: '/* leading */ EXPLAIN ANALYZE DELETE FROM users', + remote: true, + }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it('skips confirmation for -- header\\nSELECT 1 (leading line comment is stripped, previously a UX nuisance)', async () => { + const root = createProjectRoot(); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], success: true })); + + await runDbCommand(root, { + action: 'execute', + command: '-- header\nSELECT 1', + remote: true, + }); + + expect(mockConfirm).not.toHaveBeenCalled(); + }); + it('prompts for confirmation for WITH (CTE) statements (fail-closed: writable CTEs are indistinguishable without a parser)', async () => { const root = createProjectRoot(); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });