diff --git a/.changeset/cli-database-url-resolution.md b/.changeset/cli-database-url-resolution.md new file mode 100644 index 00000000..4c01fa06 --- /dev/null +++ b/.changeset/cli-database-url-resolution.md @@ -0,0 +1,37 @@ +--- +'@cipherstash/cli': minor +--- + +Layered `DATABASE_URL` resolution for DB / schema commands. + +Previously, any DB-touching command (`db install`, `db push`, `db upgrade`, `db status`, `db validate`, `db test-connection`, `schema build`) failed with the cryptic Zod error: + +``` +Error: Invalid stash.config.ts + - databaseUrl: Invalid input: expected nonoptional, received undefined +``` + +if `DATABASE_URL` wasn't already in the environment. The CLI auto-loaded `.env.local` / `.env.development.local` / `.env.development` / `.env`, but had no story for `--database-url` flags, local Supabase, or pasted-once values. + +The scaffolded `stash.config.ts` now calls a resolver directly: + +```ts +import { defineConfig, resolveDatabaseUrl } from '@cipherstash/cli' + +export default defineConfig({ + databaseUrl: await resolveDatabaseUrl(), + client: './src/encryption/index.ts', +}) +``` + +`resolveDatabaseUrl()` walks sources in order; first hit wins: + +1. `--database-url ` flag — new, accepted on all seven DB / schema commands. Used for this run only; never written to disk. +2. `process.env.DATABASE_URL` — covers shell exports, mise, direnv, dotenv-cli, the existing dotenv loads. +3. `supabase status --output env` → `DB_URL` — auto-engaged when `--supabase` is set or a `supabase/config.toml` is detected. Useful for local Supabase users who haven't exported the URL yet. +4. Interactive prompt — opens with a tip listing the alternatives (flag, env, the user's actual dotenv file). Skipped under `CI=true` or non-TTY stdin. +5. Hard fail with a source-naming error message. + +The connection string is **never persisted to disk** — `stash.config.ts` only contains the `await resolveDatabaseUrl()` call, never a literal URL. The resolver also doesn't mutate `process.env`; CLI flag context is threaded into the config evaluation via `AsyncLocalStorage` so concurrent loads stay isolated. Source labels are logged on non-env paths (`Using DATABASE_URL from --database-url flag` / `from supabase status` / `from prompt`) but the URL itself is never echoed. + +`db test-connection`'s connection-failure hint is now source-aware: it points users at `--database-url`, the env var, and the actual dotenv file in their project (`.env.local` if present, `.env` otherwise) — not the misleading `stash.config.ts` it used to suggest. diff --git a/AGENTS.md b/AGENTS.md index 032a27df..d51eb7da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,27 @@ pnpm changeset:publish - `pnpm --filter build` - `pnpm --filter test` 6. Update docs in `docs/*` and usage examples if APIs change. +7. **Add a changeset before opening or finalising the PR** when the + change affects a published package's public behaviour or surface + (new feature, bug fix, breaking change, UX-visible tweak). Run + `pnpm changeset` (interactive) or hand-write a markdown file under + `.changeset/` matching the existing format: + + ``` + --- + '@cipherstash/': minor # or patch / major + --- + + + ``` + + The repo's `changeset-bot` GitHub app posts a "🦋 No Changeset + found" warning on PRs missing one. Skip changesets only for + internal-only changes (test-only PRs, internal refactors with no + observable behaviour change, repo tooling). When in doubt, add + one — releases use Changesets to drive version bumps and + `CHANGELOG.md` entries, so a missing changeset means the change + ships invisibly. ## Useful Links in this repo diff --git a/packages/cli/src/__tests__/database-url.test.ts b/packages/cli/src/__tests__/database-url.test.ts new file mode 100644 index 00000000..3ec42d63 --- /dev/null +++ b/packages/cli/src/__tests__/database-url.test.ts @@ -0,0 +1,317 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { messages } from '../messages.js' + +// Mock seams. Hoisted so the in-test reconfiguration touches the same fn +// instances the resolver imports. +const supabase = vi.hoisted(() => ({ execSync: vi.fn() })) +vi.mock('node:child_process', () => ({ execSync: supabase.execSync })) + +const detect = vi.hoisted(() => ({ detectSupabaseProject: vi.fn() })) +vi.mock('../commands/db/detect.js', () => ({ + detectSupabaseProject: detect.detectSupabaseProject, +})) + +const clack = vi.hoisted(() => ({ + text: vi.fn(), + isCancel: vi.fn(), + cancel: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + note: vi.fn(), +})) +vi.mock('@clack/prompts', () => ({ + text: clack.text, + isCancel: clack.isCancel, + cancel: clack.cancel, + log: clack.log, + note: clack.note, +})) + +const { resolveDatabaseUrl, withResolverContext } = await import( + '../config/database-url.js' +) + +const VALID_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres' + +let originalEnv: string | undefined +let originalCi: string | undefined +let originalIsTty: boolean | undefined +let tmpDir: string + +function noProject() { + detect.detectSupabaseProject.mockReturnValue({ + hasMigrationsDir: false, + hasConfigToml: false, + migrationsDir: '/tmp/x', + }) +} + +beforeEach(() => { + originalEnv = process.env.DATABASE_URL + originalCi = process.env.CI + originalIsTty = process.stdin.isTTY + // biome-ignore lint/performance/noDelete: process.env.X = undefined assigns the string "undefined" in Node, not unset. + delete process.env.DATABASE_URL + // biome-ignore lint/performance/noDelete: ditto. + delete process.env.CI + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'database-url-test-')) + noProject() +}) + +afterEach(() => { + if (originalEnv === undefined) { + // biome-ignore lint/performance/noDelete: see above. + delete process.env.DATABASE_URL + } else { + process.env.DATABASE_URL = originalEnv + } + if (originalCi === undefined) { + // biome-ignore lint/performance/noDelete: see above. + delete process.env.CI + } else { + process.env.CI = originalCi + } + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTty, + configurable: true, + }) + // restoreAllMocks (not clearAllMocks) is what fully reverts spies on + // global objects like `process.exit`. Without restoration the spy stays + // attached and bleeds into later tests. + vi.restoreAllMocks() + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +}) + +describe('resolveDatabaseUrl — flag source', () => { + it('returns the flag value and does NOT mutate process.env', async () => { + process.env.DATABASE_URL = 'postgresql://existing@h/d' + const result = await resolveDatabaseUrl({ databaseUrlFlag: VALID_URL }) + expect(result).toBe(VALID_URL) + // The whole point of the ALS refactor: env stays untouched. + expect(process.env.DATABASE_URL).toBe('postgresql://existing@h/d') + expect(clack.log.info).toHaveBeenCalledWith(messages.db.urlResolvedFromFlag) + }) + + it('reads the flag from withResolverContext when no explicit opts are passed', async () => { + const result = await withResolverContext( + { databaseUrlFlag: VALID_URL }, + () => resolveDatabaseUrl(), + ) + expect(result).toBe(VALID_URL) + expect(clack.log.info).toHaveBeenCalledWith(messages.db.urlResolvedFromFlag) + }) + + it('exits 1 when the flag is malformed', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect( + resolveDatabaseUrl({ databaseUrlFlag: 'not-a-url' }), + ).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlFlagMalformed) + }) +}) + +describe('resolveDatabaseUrl — env source', () => { + it('returns the existing env value without mutating it', async () => { + process.env.DATABASE_URL = VALID_URL + const result = await resolveDatabaseUrl() + expect(result).toBe(VALID_URL) + expect(process.env.DATABASE_URL).toBe(VALID_URL) + // The env source is silent — no source label. + expect(clack.log.info).not.toHaveBeenCalled() + }) + + it('treats empty-string env as unset and falls through', async () => { + process.env.DATABASE_URL = '' + process.env.CI = 'true' + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi) + }) +}) + +describe('resolveDatabaseUrl — supabase source', () => { + it('parses DB_URL from `supabase status --output env` and does NOT mutate process.env', async () => { + detect.detectSupabaseProject.mockReturnValue({ + hasMigrationsDir: true, + hasConfigToml: true, + migrationsDir: '/tmp/x', + }) + supabase.execSync.mockReturnValueOnce(`API_URL=http://127.0.0.1:54321 +DB_URL=${VALID_URL} +GRAPHQL_URL=http://127.0.0.1:54321/graphql/v1 +`) + const result = await resolveDatabaseUrl() + expect(result).toBe(VALID_URL) + // No env mutation under the new design. + expect(process.env.DATABASE_URL).toBeUndefined() + expect(clack.log.info).toHaveBeenCalledWith( + messages.db.urlResolvedFromSupabase, + ) + }) + + it('falls through when supabase binary not found', async () => { + detect.detectSupabaseProject.mockReturnValue({ + hasMigrationsDir: false, + hasConfigToml: true, + migrationsDir: '/tmp/x', + }) + supabase.execSync.mockImplementation(() => { + throw new Error('command not found') + }) + process.env.CI = 'true' + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('falls through when supabase env output has no DB_URL', async () => { + detect.detectSupabaseProject.mockReturnValue({ + hasMigrationsDir: false, + hasConfigToml: true, + migrationsDir: '/tmp/x', + }) + supabase.execSync.mockReturnValue('API_URL=http://127.0.0.1:54321\n') + process.env.CI = 'true' + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + }) + + it('does NOT call supabase when no project is detected and no --supabase flag', async () => { + process.env.CI = 'true' + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(supabase.execSync).not.toHaveBeenCalled() + }) +}) + +describe('resolveDatabaseUrl — prompt source', () => { + beforeEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + }) + + it('shows the alternatives tip with the detected dotenv file before prompting', async () => { + fs.writeFileSync(path.join(tmpDir, '.env.local'), '') + clack.text.mockResolvedValueOnce(VALID_URL) + clack.isCancel.mockReturnValueOnce(false) + await resolveDatabaseUrl({ cwd: tmpDir }) + expect(clack.note).toHaveBeenCalledWith( + messages.db.urlPromptTip('.env.local'), + ) + expect(clack.text).toHaveBeenCalled() + }) + + it('defaults the prompt tip to .env when no dotenv files exist', async () => { + clack.text.mockResolvedValueOnce(VALID_URL) + clack.isCancel.mockReturnValueOnce(false) + await resolveDatabaseUrl({ cwd: tmpDir }) + expect(clack.note).toHaveBeenCalledWith(messages.db.urlPromptTip('.env')) + }) + + it('returns the entered URL and suggests an existing dotenv file', async () => { + fs.writeFileSync(path.join(tmpDir, '.env.local'), '') + clack.text.mockResolvedValueOnce(VALID_URL) + clack.isCancel.mockReturnValueOnce(false) + const result = await resolveDatabaseUrl({ cwd: tmpDir }) + expect(result).toBe(VALID_URL) + // No env mutation. + expect(process.env.DATABASE_URL).toBeUndefined() + expect(clack.note).toHaveBeenCalledWith(messages.db.urlHint('.env.local')) + }) + + it('defaults the hint file to .env when no dotenv files exist', async () => { + clack.text.mockResolvedValueOnce(VALID_URL) + clack.isCancel.mockReturnValueOnce(false) + await resolveDatabaseUrl({ cwd: tmpDir }) + expect(clack.note).toHaveBeenCalledWith(messages.db.urlHint('.env')) + }) + + it('exits 0 when the user cancels the prompt', async () => { + const cancelSym = Symbol('clack:cancel') + clack.text.mockResolvedValueOnce(cancelSym) + clack.isCancel.mockReturnValueOnce(true) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl({ cwd: tmpDir })).rejects.toThrow( + 'process.exit', + ) + expect(exitSpy).toHaveBeenCalledWith(0) + }) +}) + +describe('resolveDatabaseUrl — CI guard', () => { + it.each(['true', 'TRUE', '1', ' true '])( + 'does not prompt and exits 1 when CI=%j (truthy)', + async (ciValue) => { + process.env.CI = ciValue + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.text).not.toHaveBeenCalled() + expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi) + }, + ) + + it('does not prompt when stdin is not a TTY (e.g. piped)', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.text).not.toHaveBeenCalled() + }) +}) + +describe('withResolverContext — concurrent isolation', () => { + // The whole reason we use AsyncLocalStorage instead of module-level + // state: two concurrent calls must each see their own options without + // stepping on each other. + it('isolates contexts across concurrent withResolverContext scopes', async () => { + const URL_A = 'postgresql://a:a@h/a' + const URL_B = 'postgresql://b:b@h/b' + + const [a, b] = await Promise.all([ + withResolverContext({ databaseUrlFlag: URL_A }, async () => { + // Yield to let the other branch start its scope before we read. + await new Promise((res) => setTimeout(res, 5)) + return resolveDatabaseUrl() + }), + withResolverContext({ databaseUrlFlag: URL_B }, async () => { + await new Promise((res) => setTimeout(res, 5)) + return resolveDatabaseUrl() + }), + ]) + + expect(a).toBe(URL_A) + expect(b).toBe(URL_B) + }) +}) diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 39e2513d..9154dac1 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -93,6 +93,7 @@ DB Flags: --migrations-dir (install, requires --supabase) Override the Supabase migrations directory (default: supabase/migrations) --exclude-operator-family (install, upgrade, validate) Skip operator family creation --latest (install, upgrade) Fetch the latest EQL from GitHub + --database-url (all db / schema commands) Override DATABASE_URL for this run only — never written to disk Examples: npx @cipherstash/cli init @@ -145,6 +146,10 @@ async function runDbCommand( flags: Record, values: Record, ) { + // Plumbed through every db subcommand so the URL resolver can use it as + // an explicit override. See packages/cli/src/config/database-url.ts. + const databaseUrl = values['database-url'] + switch (sub) { case 'install': await installCommand({ @@ -159,6 +164,7 @@ async function runDbCommand( migration: flags.migration, direct: flags.direct, migrationsDir: values['migrations-dir'], + databaseUrl, }) break case 'upgrade': @@ -167,13 +173,14 @@ async function runDbCommand( supabase: flags.supabase, excludeOperatorFamily: flags['exclude-operator-family'], latest: flags.latest, + databaseUrl, }) break case 'push': { const { pushCommand } = await requireStack( () => import('../commands/db/push.js'), ) - await pushCommand({ dryRun: flags['dry-run'] }) + await pushCommand({ dryRun: flags['dry-run'], databaseUrl }) break } case 'validate': { @@ -183,14 +190,15 @@ async function runDbCommand( await validateCommand({ supabase: flags.supabase, excludeOperatorFamily: flags['exclude-operator-family'], + databaseUrl, }) break } case 'status': - await statusCommand() + await statusCommand({ databaseUrl }) break case 'test-connection': - await testConnectionCommand() + await testConnectionCommand({ databaseUrl }) break case 'migrate': p.log.warn(messages.db.migrateNotImplemented) @@ -206,13 +214,17 @@ async function runDbCommand( async function runSchemaCommand( sub: string | undefined, flags: Record, + values: Record, ) { switch (sub) { case 'build': { const { builderCommand } = await requireStack( () => import('../commands/schema/build.js'), ) - await builderCommand({ supabase: flags.supabase }) + await builderCommand({ + supabase: flags.supabase, + databaseUrl: values['database-url'], + }) break } default: @@ -251,7 +263,7 @@ async function main() { await runDbCommand(subcommand, flags, values) break case 'schema': - await runSchemaCommand(subcommand, flags) + await runSchemaCommand(subcommand, flags, values) break case 'env': await envCommand({ write: flags.write }) diff --git a/packages/cli/src/commands/db/config-scaffold.ts b/packages/cli/src/commands/db/config-scaffold.ts index fac79e78..f45cb115 100644 --- a/packages/cli/src/commands/db/config-scaffold.ts +++ b/packages/cli/src/commands/db/config-scaffold.ts @@ -69,10 +69,14 @@ export async function resolveClientPath( } function generateConfig(clientPath: string): string { - return `import { defineConfig } from '@cipherstash/cli' + // The config calls resolveDatabaseUrl() at evaluation time. The CLI + // walks a layered chain (--database-url flag → env → supabase status + // → interactive prompt) and returns a usable URL. The connection + // string is never persisted — only this declarative call is. + return `import { defineConfig, resolveDatabaseUrl } from '@cipherstash/cli' export default defineConfig({ - databaseUrl: process.env.DATABASE_URL!, + databaseUrl: await resolveDatabaseUrl(), client: '${clientPath}', }) ` diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 02d46e2c..19aa4f31 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process' import { existsSync, unlinkSync, writeFileSync } from 'node:fs' import { readdir } from 'node:fs/promises' import { join, resolve } from 'node:path' +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadStashConfig } from '@/config/index.js' import { EQLInstaller, @@ -54,13 +55,18 @@ export interface InstallOptions { * Defaults to `/supabase/migrations`. */ migrationsDir?: string + /** + * Connection string passed via `--database-url`. Used for this run only — + * never persisted. See `src/config/database-url.ts`. + */ + databaseUrl?: string } /** Resolved install mode for the Supabase non-Drizzle branch. */ export type SupabaseInstallMode = 'migration' | 'direct' export async function installCommand(options: InstallOptions) { - p.intro('npx @cipherstash/cli db install') + p.intro(runnerCommand(detectPackageManager(), '@cipherstash/cli db install')) // Validate mutually-exclusive / supabase-required flags BEFORE doing any // I/O. `--migration` and `--direct` only make sense in the Supabase flow; @@ -84,7 +90,10 @@ export async function installCommand(options: InstallOptions) { const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }) s.stop('Configuration loaded.') // Safety net: if the user ran `db install` without first running `init`, diff --git a/packages/cli/src/commands/db/push.ts b/packages/cli/src/commands/db/push.ts index 7f5abc00..c39b76c0 100644 --- a/packages/cli/src/commands/db/push.ts +++ b/packages/cli/src/commands/db/push.ts @@ -1,3 +1,4 @@ +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadEncryptConfig, loadStashConfig } from '@/config/index.js' import type { EncryptConfig } from '@cipherstash/stack/schema' import { toEqlCastAs } from '@cipherstash/stack/schema' @@ -27,8 +28,11 @@ function toEqlConfig(config: EncryptConfig): Record { return { v: config.v, tables } } -export async function pushCommand(options: { dryRun?: boolean }) { - p.intro('npx @cipherstash/cli db push') +export async function pushCommand(options: { + dryRun?: boolean + databaseUrl?: string +}) { + p.intro(runnerCommand(detectPackageManager(), '@cipherstash/cli db push')) p.log.info( 'This command pushes the encryption schema to the database for use with CipherStash Proxy.\nIf you are using the SDK directly (Drizzle, Supabase, or plain PostgreSQL), this step is not required.', ) @@ -36,7 +40,7 @@ export async function pushCommand(options: { dryRun?: boolean }) { const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl }) s.stop('Configuration loaded.') s.start(`Loading encrypt client from ${config.client}...`) diff --git a/packages/cli/src/commands/db/status.ts b/packages/cli/src/commands/db/status.ts index 08c6b43d..78ef209d 100644 --- a/packages/cli/src/commands/db/status.ts +++ b/packages/cli/src/commands/db/status.ts @@ -1,15 +1,17 @@ +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadStashConfig } from '@/config/index.js' import { EQLInstaller } from '@/installer/index.js' import * as p from '@clack/prompts' import pg from 'pg' -export async function statusCommand() { - p.intro('npx @cipherstash/cli db status') +export async function statusCommand(options: { databaseUrl?: string } = {}) { + const pm = detectPackageManager() + p.intro(runnerCommand(pm, '@cipherstash/cli db status')) const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl }) s.stop('Configuration loaded.') const installer = new EQLInstaller({ @@ -41,7 +43,9 @@ export async function statusCommand() { p.log.success(`EQL installed: yes (version: ${version ?? 'unknown'})`) } else { s.stop('EQL is not installed.') - p.log.warn('EQL is not installed. Run `npx @cipherstash/cli db install` to install it.') + p.log.warn( + `EQL is not installed. Run \`${runnerCommand(pm, '@cipherstash/cli db install')}\` to install it.`, + ) p.outro('Status check complete.') return } @@ -100,7 +104,7 @@ export async function statusCommand() { const message = error instanceof Error ? error.message : String(error) if (message.includes('does not exist')) { p.log.info( - 'Active encrypt config: table not found (run `npx @cipherstash/cli db push` to create it)', + `Active encrypt config: table not found (run \`${runnerCommand(pm, '@cipherstash/cli db push')}\` to create it)`, ) } else { p.log.error(`Failed to check encrypt configuration: ${message}`) diff --git a/packages/cli/src/commands/db/test-connection.ts b/packages/cli/src/commands/db/test-connection.ts index dbf7caac..990feb52 100644 --- a/packages/cli/src/commands/db/test-connection.ts +++ b/packages/cli/src/commands/db/test-connection.ts @@ -1,14 +1,24 @@ +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { detectDotenvFile } from '@/config/database-url.js' import { loadStashConfig } from '@/config/index.js' +import { messages } from '@/messages.js' import * as p from '@clack/prompts' import pg from 'pg' -export async function testConnectionCommand() { - p.intro('npx @cipherstash/cli db test-connection') +export async function testConnectionCommand( + options: { databaseUrl?: string } = {}, +) { + p.intro( + runnerCommand( + detectPackageManager(), + '@cipherstash/cli db test-connection', + ), + ) const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl }) s.stop('Configuration loaded.') const client = new pg.Client({ connectionString: config.databaseUrl }) @@ -45,7 +55,7 @@ export async function testConnectionCommand() { p.log.error(`Failed to connect to database: ${message}`) console.log() - p.log.info('Check your databaseUrl in stash.config.ts or .env file.') + p.log.info(messages.db.urlConnectionFailedHint(detectDotenvFile())) process.exit(1) } finally { await client.end() diff --git a/packages/cli/src/commands/db/upgrade.ts b/packages/cli/src/commands/db/upgrade.ts index c54edd99..fb7f5bcb 100644 --- a/packages/cli/src/commands/db/upgrade.ts +++ b/packages/cli/src/commands/db/upgrade.ts @@ -1,3 +1,4 @@ +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadStashConfig } from '@/config/index.js' import { EQLInstaller } from '@/installer/index.js' import * as p from '@clack/prompts' @@ -7,13 +8,18 @@ export async function upgradeCommand(options: { supabase?: boolean excludeOperatorFamily?: boolean latest?: boolean + databaseUrl?: string }) { - p.intro('npx @cipherstash/cli db upgrade') + const pm = detectPackageManager() + p.intro(runnerCommand(pm, '@cipherstash/cli db upgrade')) const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }) s.stop('Configuration loaded.') const installer = new EQLInstaller({ @@ -26,7 +32,7 @@ export async function upgradeCommand(options: { if (!installed) { s.stop('EQL is not installed.') p.log.warn( - 'EQL is not currently installed. Run "npx @cipherstash/cli db install" first.', + `EQL is not currently installed. Run "${runnerCommand(pm, '@cipherstash/cli db install')}" first.`, ) p.outro('Upgrade aborted.') process.exit(1) diff --git a/packages/cli/src/commands/db/validate.ts b/packages/cli/src/commands/db/validate.ts index 56a4011c..db7c27e6 100644 --- a/packages/cli/src/commands/db/validate.ts +++ b/packages/cli/src/commands/db/validate.ts @@ -1,3 +1,4 @@ +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadEncryptConfig, loadStashConfig } from '@/config/index.js' import type { EncryptConfig } from '@cipherstash/stack/schema' import * as p from '@clack/prompts' @@ -12,7 +13,17 @@ interface ValidationIssue { } /** Cast-as types that are not string-like — free-text search is meaningless for these. */ -const NON_STRING_CAST_TYPES = new Set(['int', 'small_int', 'big_int', 'real', 'double', 'boolean', 'date', 'number', 'bigint']) +const NON_STRING_CAST_TYPES = new Set([ + 'int', + 'small_int', + 'big_int', + 'real', + 'double', + 'boolean', + 'date', + 'number', + 'bigint', +]) /** * Validate an EncryptConfig against common misconfiguration rules. @@ -137,13 +148,17 @@ export function reportIssues(issues: ValidationIssue[]): boolean { export async function validateCommand(options: { supabase?: boolean excludeOperatorFamily?: boolean + databaseUrl?: string }) { - p.intro('npx @cipherstash/cli db validate') + p.intro(runnerCommand(detectPackageManager(), '@cipherstash/cli db validate')) const s = p.spinner() s.start('Loading stash.config.ts...') - const config = await loadStashConfig() + const config = await loadStashConfig({ + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }) s.stop('Configuration loaded.') s.start(`Loading encrypt client from ${config.client}...`) diff --git a/packages/cli/src/commands/schema/build.ts b/packages/cli/src/commands/schema/build.ts index 599984e0..f2435e07 100644 --- a/packages/cli/src/commands/schema/build.ts +++ b/packages/cli/src/commands/schema/build.ts @@ -118,7 +118,6 @@ function drizzleTsType(dataType: string): string { } } - function generateClientFromSchemas( integration: Integration, schemas: SchemaDef[], @@ -167,9 +166,7 @@ ${columnDefs.join('\n')} const ${schemaVarName} = extractEncryptionSchema(${varName})` }) - const schemaVarNames = schemas.map( - (s) => `${toCamelCase(s.tableName)}Schema`, - ) + const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' @@ -206,9 +203,7 @@ ${columnDefs.join('\n')} })` }) - const tableVarNames = schemas.map( - (s) => `${toCamelCase(s.tableName)}Table`, - ) + const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' import { Encryption } from '@cipherstash/stack' @@ -340,8 +335,13 @@ async function buildSchemasFromDatabase( // --- Command --- -export async function builderCommand(options: { supabase?: boolean } = {}) { - const config = await loadStashConfig() +export async function builderCommand( + options: { supabase?: boolean; databaseUrl?: string } = {}, +) { + const config = await loadStashConfig({ + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }) p.intro('CipherStash Schema Builder') diff --git a/packages/cli/src/config/database-url.ts b/packages/cli/src/config/database-url.ts new file mode 100644 index 00000000..48466b3b --- /dev/null +++ b/packages/cli/src/config/database-url.ts @@ -0,0 +1,220 @@ +/** + * Layered DATABASE_URL resolution. Called from inside the user's + * `stash.config.ts` via: + * + * import { defineConfig, resolveDatabaseUrl } from '@cipherstash/cli' + * export default defineConfig({ + * databaseUrl: await resolveDatabaseUrl(), + * }) + * + * The CLI's `loadStashConfig` wraps the jiti-import in + * `withResolverContext({ databaseUrlFlag, supabase })` (an + * `AsyncLocalStorage` scope) before evaluating the config file. Any + * `resolveDatabaseUrl()` call inside the file then sees those options + * via `als.getStore()` and walks: + * + * 1. `--database-url ` flag (explicit override). + * 2. `process.env.DATABASE_URL` (shell, mise, direnv, dotenv files + * loaded by `bin/stash.ts`). + * 3. `supabase status --output env` → `DB_URL`, when `--supabase` is + * set OR a `supabase/config.toml` is detected. + * 4. Interactive `p.text` prompt (skipped under `CI=true` or non-TTY + * stdin). + * 5. Hard-fail with a source-naming error. + * + * Returns the resolved URL string. The CLI never mutates + * `process.env.DATABASE_URL` — the URL is only carried in the value + * `defineConfig` returns. The connection string is never persisted to + * disk; `stash.config.ts` references this function, not a literal. + * + * Concurrency: the ALS context is per-async-flow, so multiple + * concurrent `loadStashConfig` calls (e.g. parallel test cases or a + * programmatic batch invocation) each get isolated options without + * stepping on each other. + */ + +import { AsyncLocalStorage } from 'node:async_hooks' +import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import * as p from '@clack/prompts' +import { detectSupabaseProject } from '../commands/db/detect.js' +import { messages } from '../messages.js' + +export interface ResolveDatabaseUrlOptions { + /** Value of `--database-url` if the user passed one. */ + databaseUrlFlag?: string + /** Value of `--supabase` flag. Triggers the supabase-status fallback. */ + supabase?: boolean + /** Override cwd for project detection (mainly for tests). */ + cwd?: string +} + +// The CLI ships as two tsup bundles (`dist/index.js` for the library and +// `dist/bin/stash.js` for the binary), each of which contains its own copy +// of this file. A bare `new AsyncLocalStorage()` would therefore produce +// two independent stores: the CLI sets context on the binary's instance, +// the user's config (loaded via jiti from inside the binary process) +// imports from the library bundle and reads from a different instance, so +// nothing propagates. Rendezvous via a `Symbol.for`-keyed slot on +// `globalThis` so both bundles share a single ALS for the lifetime of the +// process. Behaviour is identical to a plain module-level `als` — the +// concurrency guarantees come from `AsyncLocalStorage`, not from where +// the instance is parked. +const ALS_KEY = Symbol.for('cipherstash.cli.database-url-als') +type AlsHolder = { + [ALS_KEY]?: AsyncLocalStorage +} +const alsHolder = globalThis as AlsHolder +if (!alsHolder[ALS_KEY]) { + alsHolder[ALS_KEY] = new AsyncLocalStorage() +} +const als = alsHolder[ALS_KEY] + +/** + * Run `fn` inside an ALS scope that exposes `opts` to any + * `resolveDatabaseUrl()` call descendant from this async-flow. + * + * Used by `loadStashConfig` to thread CLI flag values into the user's + * config evaluation without mutating `process.env` or any other shared + * state. + */ +export function withResolverContext( + opts: ResolveDatabaseUrlOptions, + fn: () => Promise, +): Promise { + return als.run(opts, fn) +} + +/** Walk dotenv precedence and pick the first existing file. Defaults to `.env`. */ +export function detectDotenvFile(cwd: string = process.cwd()): string { + const candidates = [ + '.env.local', + '.env.development.local', + '.env.development', + '.env', + ] + for (const file of candidates) { + if (existsSync(join(cwd, file))) return file + } + return '.env' +} + +function isUrlParseable(value: string): boolean { + try { + new URL(value) + return true + } catch { + return false + } +} + +/** Try to extract a `DB_URL=...` value from `supabase status --output env`. */ +function trySupabaseStatus(): string | undefined { + const candidates = [ + ['supabase', ['status', '--output', 'env']], + ['npx', ['--no-install', 'supabase', 'status', '--output', 'env']], + ] as const + + for (const [cmd, args] of candidates) { + try { + const out = execSync(`${cmd} ${args.join(' ')}`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + }) + const match = out.match(/^(?:DB_URL|db_url)=(?:"([^"]+)"|(\S+))/m) + const value = match?.[1] ?? match?.[2] + if (value && isUrlParseable(value)) return value + } catch { + // binary missing, project not started, parse error — fall through. + } + } + return undefined +} + +async function promptForUrl(cwd: string): Promise { + // Surface the alternative paths before prompting so users don't feel + // like they're stuck in an interactive flow when a flag or env var + // would do. The dotenv file in the tip is detected from cwd so it + // matches what the user actually has (`.env.local` vs `.env` etc.). + p.note(messages.db.urlPromptTip(detectDotenvFile(cwd))) + + const value = await p.text({ + message: messages.db.urlPromptMessage, + validate: (v) => { + if (!v || v.trim().length === 0) return messages.db.urlInvalid + if (!isUrlParseable(v.trim())) return messages.db.urlInvalid + return undefined + }, + }) + if (p.isCancel(value)) { + p.cancel(messages.auth.cancelled) + process.exit(0) + } + return value.trim() +} + +/** + * Walk the resolution chain and return a usable DATABASE_URL. Reads + * options from the surrounding `withResolverContext` scope (set by the + * CLI before evaluating the config file); any explicit `opts` passed + * here override the scoped values. + * + * Exits 1 when no source resolves a URL. + */ +export async function resolveDatabaseUrl( + opts: ResolveDatabaseUrlOptions = {}, +): Promise { + const ctx: ResolveDatabaseUrlOptions = { ...als.getStore(), ...opts } + const cwd = ctx.cwd ?? process.cwd() + + // 1. Flag. + if (ctx.databaseUrlFlag !== undefined) { + const trimmed = ctx.databaseUrlFlag.trim() + if (!trimmed || !isUrlParseable(trimmed)) { + p.log.error(messages.db.urlFlagMalformed) + process.exit(1) + } + p.log.info(messages.db.urlResolvedFromFlag) + return trimmed + } + + // 2. Existing env (shell, mise, direnv, dotenv files). + const fromEnv = process.env.DATABASE_URL?.trim() + if (fromEnv && fromEnv.length > 0) { + return fromEnv + } + + // 3. Supabase fallback — opted-in, or the project clearly is one. + const supabaseProject = detectSupabaseProject(cwd) + if (ctx.supabase || supabaseProject.hasConfigToml) { + const fromSupabase = trySupabaseStatus() + if (fromSupabase) { + p.log.info(messages.db.urlResolvedFromSupabase) + return fromSupabase + } + } + + // 4. Interactive prompt — skipped in CI / non-TTY. + // Accept the common CI-truthy spellings (`true`, `1`, case-insensitive) + // since not every CI provider sets `CI=true` exactly. + const ciVar = process.env.CI?.trim() + const isCi = ciVar !== undefined && /^(1|true)$/i.test(ciVar) + const isInteractive = Boolean(process.stdin.isTTY) && !isCi + if (isInteractive) { + const fromPrompt = await promptForUrl(cwd) + if (fromPrompt) { + p.log.info(messages.db.urlResolvedFromPrompt) + // Hint the user toward making it stick so they don't get re-prompted. + p.note(messages.db.urlHint(detectDotenvFile(cwd))) + return fromPrompt + } + } + + // 5. Hard fail. + p.log.error( + isCi ? messages.db.urlMissingCi : messages.db.urlMissingInteractive, + ) + process.exit(1) +} diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 8c35a6b6..2f992eb7 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -3,6 +3,10 @@ import path from 'node:path' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptConfig } from '@cipherstash/stack/schema' import { z } from 'zod' +import { + type ResolveDatabaseUrlOptions, + withResolverContext, +} from './database-url.js' export interface StashConfig { /** PostgreSQL connection string */ @@ -77,9 +81,18 @@ function findConfigFile(startDir: string): string | undefined { * Searches from `process.cwd()` upward. Uses `jiti` to evaluate the * TypeScript config file at runtime without a separate compile step. * + * The optional `resolverOptions` argument is threaded into an + * `AsyncLocalStorage` scope around the jiti-import call, so that any + * `await resolveDatabaseUrl()` inside the user's config file picks up + * `--database-url` / `--supabase` flag values from the surrounding CLI + * command. This is how the CLI passes flag context into config + * evaluation without mutating `process.env` or relying on globals. + * * Exits with code 1 if the config file is not found or fails validation. */ -export async function loadStashConfig(): Promise { +export async function loadStashConfig( + resolverOptions: ResolveDatabaseUrlOptions = {}, +): Promise { const configPath = findConfigFile(process.cwd()) if (!configPath) { @@ -87,10 +100,10 @@ export async function loadStashConfig(): Promise { Create a ${CONFIG_FILENAME} file in your project root: - import { defineConfig } from '@cipherstash/cli' + import { defineConfig, resolveDatabaseUrl } from '@cipherstash/cli' export default defineConfig({ - databaseUrl: process.env.DATABASE_URL!, + databaseUrl: await resolveDatabaseUrl(), }) `) process.exit(1) @@ -109,7 +122,9 @@ Create a ${CONFIG_FILENAME} file in your project root: // wrapper would then fail Zod validation with a misleading // "databaseUrl: received undefined" even when the user's config sets // it (#374). - rawConfig = await jiti.import(configPath, { default: true }) + rawConfig = await withResolverContext(resolverOptions, () => + jiti.import(configPath, { default: true }), + ) } catch (error) { console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`) console.error(error) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8962320c..9f50052e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,8 @@ export { defineConfig, loadStashConfig } from './config/index.ts' export type { StashConfig } from './config/index.ts' +export { resolveDatabaseUrl } from './config/database-url.ts' +export type { ResolveDatabaseUrlOptions } from './config/database-url.ts' export { EQLInstaller, loadBundledEqlSql, diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 75238656..30153686 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -25,5 +25,34 @@ export const messages = { unknownSubcommand: 'Unknown db subcommand', migrateNotImplemented: '"npx @cipherstash/cli db migrate" is not yet implemented.', + /** Source labels surfaced after DATABASE_URL resolution. */ + urlResolvedFromFlag: 'Using DATABASE_URL from --database-url flag', + urlResolvedFromSupabase: 'Using DATABASE_URL from supabase status', + urlResolvedFromPrompt: 'Using DATABASE_URL from prompt', + urlPromptMessage: 'Paste your DATABASE_URL', + /** + * Shown immediately before the URL prompt to surface alternatives. + * `dotenvFile` is the first existing dotenv file in the project (or + * `.env` as the default) so the suggestion matches the user's setup. + */ + urlPromptTip: (dotenvFile: string) => + `Tip: you can also pass --database-url on the command line, or set DATABASE_URL in your environment / ${dotenvFile} file.`, + /** + * Shown when a connection attempt fails — points the user at where + * to fix the URL. Same dotenv detection as `urlPromptTip` so the + * suggestion matches their setup. + */ + urlConnectionFailedHint: (dotenvFile: string) => + `Check that DATABASE_URL is correct. You can pass --database-url on the command line, set DATABASE_URL in your environment, or write it to ${dotenvFile}.`, + urlInvalid: 'Not a valid URL', + urlFlagMalformed: + 'Invalid --database-url: not a parseable connection string', + urlMissingCi: + 'Cannot resolve DATABASE_URL in CI. Pass --database-url or set DATABASE_URL.', + urlMissingInteractive: + 'Cannot resolve DATABASE_URL. Pass --database-url, set DATABASE_URL in your environment, or run `supabase start` if this is a Supabase project.', + /** Nudge shown after a prompt-sourced run completes. */ + urlHint: (file: string) => + `Set DATABASE_URL in ${file} to skip this prompt next time.`, }, } as const diff --git a/packages/cli/tests/e2e/database-url.e2e.test.ts b/packages/cli/tests/e2e/database-url.e2e.test.ts new file mode 100644 index 00000000..4757871c --- /dev/null +++ b/packages/cli/tests/e2e/database-url.e2e.test.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { render } from '../helpers/pty.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// Absolute path to the built CLI's `dist/index.js`. The test config imports +// from this path because the tmp dir doesn't have a node_modules with +// `@cipherstash/cli` symlinked. Real users get a clean +// `import { resolveDatabaseUrl } from '@cipherstash/cli'`. +const CLI_DIST_INDEX = path.resolve(__dirname, '../../dist/index.js') + +describe('db test-connection — DATABASE_URL resolver', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-db-url-e2e-')) + // Config calls resolveDatabaseUrl() at evaluation time. The CLI's + // loadStashConfig wraps the jiti-import in withResolverContext so the + // function picks up `--database-url` / `--supabase` flags. + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + `import { resolveDatabaseUrl } from '${CLI_DIST_INDEX}' + export default { + databaseUrl: await resolveDatabaseUrl(), + }`, + ) + }) + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('uses --database-url flag and surfaces the source label', async () => { + // Bogus host:port — connection will fail after the resolver succeeds. + // The test asserts on the log line + non-zero exit, NOT on the + // specific connection error (avoids flake if the port is in use). + const r = render( + [ + 'db', + 'test-connection', + '--database-url', + 'postgresql://x:x@127.0.0.1:1/x', + ], + { cwd: tmpDir, env: { CI: 'false', DATABASE_URL: '' } }, + ) + + await r.waitFor(messages.db.urlResolvedFromFlag, 10_000) + const { exitCode } = await r.exit + expect(exitCode).not.toBe(0) + expect(r.output).toContain(messages.db.urlResolvedFromFlag) + // Belt-and-braces: the URL itself must never appear except where the + // user typed it (here, on argv — but the spawned process's logs + // shouldn't echo it back). + expect(r.output).not.toContain('postgresql://x:x@127.0.0.1:1/x') + // Connection failed → the failure hint should point users at the + // sources they can actually fix (flag / env / dotenv file). The tmp + // dir has no dotenv files so the hint defaults to `.env`. The OLD + // misleading "Check your databaseUrl in stash.config.ts" message + // must NOT appear — the URL doesn't live in that file anymore. + expect(r.output).toContain(messages.db.urlConnectionFailedHint('.env')) + expect(r.output).not.toContain('stash.config.ts or .env file') + }) + + it('CI=true with no DATABASE_URL and no flag exits 1 with the CI message', async () => { + const r = render(['db', 'test-connection'], { + cwd: tmpDir, + env: { CI: 'true', DATABASE_URL: '' }, + }) + + const { exitCode } = await r.exit + expect(exitCode).toBe(1) + expect(r.output).toContain(messages.db.urlMissingCi) + }) +})