diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6d1984ef8..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 @@ -289,9 +290,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 +314,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/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..152141c3d 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -11,13 +11,23 @@ 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 database (use --remote for the deployed database)', + flags: [{ name: '--remote', description: 'Run against the deployed 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 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' }, { name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' }, { @@ -38,9 +48,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 +126,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 +142,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') { @@ -149,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 c8ae7a557..6a601280c 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -1,9 +1,14 @@ 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 { + readConfig, + getDatabaseDialect, + resolveBindingNames, + type DatabaseDialect, +} from '../config.ts'; import { collectMigrations, readJournal, @@ -18,8 +23,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,11 +155,11 @@ 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') { - 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') { @@ -405,12 +412,216 @@ async function runSeed( outro('Done.'); } +/** + * Returns true when the SQL is read-only (SELECT / EXPLAIN only). + * 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 + * 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. + * - `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. + * - `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 { + const decommented = sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' '); + const stripped = decommented.replace(/;\s*$/, ''); + if (stripped.includes(';')) { + return false; + } + if (/^\s*SELECT\b/i.test(stripped)) { + return true; + } + 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. + */ +async function confirmDestructiveRemote(sql: string): Promise { + if (isSafeStatement(sql)) { + 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({ + 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) { + // 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); + 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 }); + const dbUrl = await fetchRemoteDatabaseUrl(client, projectConfig.projectId); + + 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. + const platformClient = new PlatformClient(token, { root }); + // 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). + const d1Resource = resources.find((r) => r.type === 'd1'); + if (d1Resource) { + binding = d1Resource.binding_name; + } + } catch { + // 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; + 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) => { @@ -635,7 +846,43 @@ 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 }); + const dbUrl = await fetchRemoteDatabaseUrl(client, projectConfig.projectId); + + 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/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 '); }); diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 1549e22d0..e0905a396 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, }); }); @@ -146,8 +157,24 @@ 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' }); + 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/db-execute-remote.test.ts b/packages/void/test/unit/db-execute-remote.test.ts new file mode 100644 index 000000000..a63efee38 --- /dev/null +++ b/packages/void/test/unit/db-execute-remote.test.ts @@ -0,0 +1,693 @@ +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<(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: (opts: { message: string }) => mockConfirm(opts), + 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'; 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: 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, + // 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('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' }]; + 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(); + // 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 })); + + 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('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 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 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 }); + mockConfirm.mockResolvedValue(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).toHaveBeenCalledTimes(1); + }); + + 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 }); + 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(); + }); +}); 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(); + }); +}); 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(); + }); +}); diff --git a/packages/void/test/unit/link.test.ts b/packages/void/test/unit/link.test.ts index 7cc0b6ae0..f608c2554 100644 --- a/packages/void/test/unit/link.test.ts +++ b/packages/void/test/unit/link.test.ts @@ -42,6 +42,25 @@ vi.mock('../../src/cli/client', () => ({ }, })); +// 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;