From e25eb22434896ed7d0751004ab8f38245db1bbfa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 22:54:24 +1000 Subject: [PATCH 1/3] feat(cli): default EQL to v3, stop recommending db push (v2/Proxy-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #585. EQL v3 is now the default for `stash eql install` / `eql upgrade` — no `--eql-version 3` required. The v2-only paths (--drizzle, --migration, --migrations-dir, --latest) require an explicit `--eql-version 2` and error with clear guidance otherwise; `stash init` pins v2 for the Drizzle flow. A single DEFAULT_EQL_VERSION constant drives the installer defaults. `stash db push` writes `public.eql_v2_configuration`, a v2 + CipherStash Proxy artifact that EQL v3 neither creates nor reads (v3 config lives in each column's `eql_v3.*` type). The CLI no longer recommends it: - `eql status` is v3-aware — reports config-in-column-types on a v3-only DB instead of the "table not found → run db push" dead-end. - `db push` guards a v3-only DB with a clean "not needed under v3" message. - Removed push recommendations from the help banner, init/plan/cutover setup-prompt guidance (dropping the now-unused usesProxy branch), and the encrypt-status / quest next-step hints. `db push` / `db activate` remain for v2 + Proxy users and are labelled as such. Tests: reworked validateInstallFlags coverage for the v3 default + explicit-v2 gating; made the v2-specific installer tests explicit about eqlVersion: 2; replaced the usesProxy setup-prompt tests with no-db-push assertions. 396 unit + 55 e2e green. --- .changeset/stash-cli-eql-v3-default.md | 10 + packages/cli/src/__tests__/installer.test.ts | 8 +- .../src/__tests__/supabase-migration.test.ts | 42 +++- packages/cli/src/bin/main.ts | 5 +- packages/cli/src/cli/__tests__/help.test.ts | 2 +- packages/cli/src/cli/registry.ts | 15 +- packages/cli/src/commands/db/activate.ts | 2 +- packages/cli/src/commands/db/install.ts | 53 +++-- packages/cli/src/commands/db/push.ts | 20 ++ packages/cli/src/commands/db/status.ts | 31 +-- packages/cli/src/commands/db/upgrade.ts | 10 +- packages/cli/src/commands/encrypt/backfill.ts | 2 +- packages/cli/src/commands/encrypt/cutover.ts | 2 +- packages/cli/src/commands/encrypt/status.ts | 2 +- .../init/lib/__tests__/setup-prompt.test.ts | 202 +++--------------- .../cli/src/commands/init/lib/setup-prompt.ts | 83 ++----- .../src/commands/init/lib/write-context.ts | 1 - .../src/commands/init/steps/install-eql.ts | 5 + packages/cli/src/commands/status/quest.ts | 2 +- packages/cli/src/installer/index.ts | 25 ++- 20 files changed, 215 insertions(+), 307 deletions(-) create mode 100644 .changeset/stash-cli-eql-v3-default.md diff --git a/.changeset/stash-cli-eql-v3-default.md b/.changeset/stash-cli-eql-v3-default.md new file mode 100644 index 00000000..ee9b3a58 --- /dev/null +++ b/.changeset/stash-cli-eql-v3-default.md @@ -0,0 +1,10 @@ +--- +"stash": minor +--- + +Default EQL to v3 and stop the CLI recommending `stash db push` (#585). + +- **EQL v3 is now the default.** `stash eql install` and `stash eql upgrade` target v3 (the native `eql_v3.*` domain schema) without `--eql-version 3`. The v2-only paths — `--drizzle`, `--migration`, `--migrations-dir`, and `--latest` — now require an explicit `--eql-version 2` and error with clear guidance otherwise (v3 installs via the direct path only). `stash init` pins v2 automatically when it drives the Drizzle migration flow. +- **`stash db push` is no longer recommended in CLI output.** `db push` writes the `public.eql_v2_configuration` table, which is a v2 + CipherStash Proxy artifact — EQL v3 has no configuration table (config lives in each column's `eql_v3.*` type) and nothing in the v3 stack reads it. The push recommendations are removed from `eql status`, the help banner, and the init/plan/cutover guidance. `db push` (and `db activate`) remain available for EQL v2 + Proxy users; they're now labelled as such. +- **`eql status` is v3-aware.** On a v3-only database it reports that encrypt config lives in the column types instead of hitting a "table not found" dead-end that told users to run `db push` (which neither creates that table nor applies to v3). +- **`stash db push` guards a v3-only database** with a clear "not needed under EQL v3" message instead of a raw `relation "public.eql_v2_configuration" does not exist` error. diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts index 6ed9eb91..82a5bd64 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -108,7 +108,7 @@ describe('EQLInstaller', () => { databaseUrl: 'postgresql://localhost:5432/test', }) - const result = await installer.isInstalled() + const result = await installer.isInstalled({ eqlVersion: 2 }) expect(result).toBe(true) expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [['eql_v2']]) }) @@ -127,7 +127,7 @@ describe('EQLInstaller', () => { databaseUrl: 'postgresql://localhost:5432/test', }) - await installer.install() + await installer.install({ eqlVersion: 2 }) // Should NOT call fetch — uses bundled SQL expect(fetchSpy).not.toHaveBeenCalled() @@ -160,7 +160,7 @@ describe('EQLInstaller', () => { databaseUrl: 'postgresql://localhost:5432/test', }) - await installer.install({ latest: true }) + await installer.install({ eqlVersion: 2, latest: true }) expect(fetchSpy).toHaveBeenCalledWith( expect.stringContaining('cipherstash-encrypt.sql'), @@ -182,7 +182,7 @@ describe('EQLInstaller', () => { databaseUrl: 'postgresql://localhost:5432/test', }) - await installer.install({ supabase: true }) + await installer.install({ eqlVersion: 2, supabase: true }) // Capture every query string that isn't a transaction control verb. const otherCalls = mockQuery.mock.calls diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts index a5c0d095..311787e5 100644 --- a/packages/cli/src/__tests__/supabase-migration.test.ts +++ b/packages/cli/src/__tests__/supabase-migration.test.ts @@ -208,8 +208,16 @@ describe('validateInstallFlags', () => { expect(validateInstallFlags({})).toBeNull() }) - it('returns null when --supabase is paired with --migration', () => { - expect(validateInstallFlags({ supabase: true, migration: true })).toBeNull() + it('returns null when --supabase + --migration is pinned to --eql-version 2', () => { + // --migration is a v2-only path, so under the v3 default it needs an + // explicit `--eql-version 2` alongside --supabase. + expect( + validateInstallFlags({ + supabase: true, + migration: true, + eqlVersion: '2', + }), + ).toBeNull() }) it('returns null when --supabase is paired with --direct', () => { @@ -279,6 +287,36 @@ describe('validateInstallFlags', () => { }), ).toMatch(/--migrations-dir/) }) + + it('defaults to v3: a plain install with no version is valid', () => { + expect(validateInstallFlags({})).toBeNull() + expect(validateInstallFlags({ supabase: true })).toBeNull() + }) + + it('v2-only flags without an explicit --eql-version 2 are rejected under the v3 default', () => { + // No eqlVersion passed → resolves to the v3 default, which can't do these. + for (const opts of [ + { drizzle: true }, + { latest: true }, + { supabase: true, migration: true }, + { supabase: true, migrationsDir: 'db/migrations' }, + ]) { + const err = validateInstallFlags(opts) + expect(err).toMatch(/--eql-version 2/) + } + }) + + it('accepts the v2-only paths when --eql-version 2 is explicit', () => { + expect(validateInstallFlags({ eqlVersion: '2', drizzle: true })).toBeNull() + expect(validateInstallFlags({ eqlVersion: '2', latest: true })).toBeNull() + expect( + validateInstallFlags({ + eqlVersion: '2', + supabase: true, + migration: true, + }), + ).toBeNull() + }) }) describe('routeInstallPathForEqlVersion', () => { diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 3a0ed517..c71794a8 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -97,8 +97,8 @@ Commands: eql upgrade Upgrade EQL extensions to the latest version eql status Show EQL installation status - db push Push encryption schema (writes pending if active config already exists) - db activate Promote pending → active without renames (use after additive db push) + db push (EQL v2 + Proxy) Push encryption schema to eql_v2_configuration + db activate (EQL v2 + Proxy) Promote pending → active without renames db validate Validate encryption schema db migrate Run pending encrypt config migrations db test-connection Test database connectivity @@ -124,7 +124,6 @@ Examples: ${STASH} init # set up CipherStash in this project ${STASH} auth login # authenticate ${STASH} eql install # install EQL extensions - ${STASH} db push # push encryption schema ${STASH} manifest --json # structured command surface for docs / agents `.trim() diff --git a/packages/cli/src/cli/__tests__/help.test.ts b/packages/cli/src/cli/__tests__/help.test.ts index 81bacb69..5e737bca 100644 --- a/packages/cli/src/cli/__tests__/help.test.ts +++ b/packages/cli/src/cli/__tests__/help.test.ts @@ -17,7 +17,7 @@ describe('renderCommandHelp', () => { expect(out).toContain('--force') // Value-taking flag renders its placeholder + default annotation. expect(out).toContain('--eql-version <2|3>') - expect(out).toContain('(default: 2)') + expect(out).toContain('(default: 3)') }) it('surfaces a flag env var alongside its description', () => { diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index eb7d3fa9..c388d5ed 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -331,12 +331,13 @@ export const registry: CommandGroup[] = [ name: '--eql-version', value: '<2|3>', description: - 'EQL generation to target. v3 is the native eql_v3.* domain schema (direct install only for now).', - default: '2', + 'EQL generation to target. v3 (native eql_v3.* domain schema) is the default; pass `2` for the legacy composite type. --drizzle, --migration, --migrations-dir, and --latest are v2-only and require `--eql-version 2`.', + default: '3', }, { name: '--latest', - description: 'Fetch the latest EQL from GitHub (v2 only).', + description: + 'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).', }, { name: '--name', @@ -363,12 +364,14 @@ export const registry: CommandGroup[] = [ { name: '--eql-version', value: '<2|3>', - description: 'EQL generation to target.', - default: '2', + description: + 'EQL generation to target. v3 is the default; pass `2` for the legacy composite type. --latest is v2-only and requires `--eql-version 2`.', + default: '3', }, { name: '--latest', - description: 'Fetch the latest EQL from GitHub (v2 only).', + description: + 'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).', }, DATABASE_URL_FLAG, ], diff --git a/packages/cli/src/commands/db/activate.ts b/packages/cli/src/commands/db/activate.ts index 8d9aafd8..86e19af7 100644 --- a/packages/cli/src/commands/db/activate.ts +++ b/packages/cli/src/commands/db/activate.ts @@ -45,7 +45,7 @@ export async function activateCommand( ) if (pending.rows[0]?.exists !== true) { p.log.error( - 'No pending EQL configuration to activate. Run `stash db push` first to register a change.', + 'No pending EQL configuration to activate. This applies to the EQL v2 + CipherStash Proxy config lifecycle — register a pending change before activating.', ) exitCode = 1 return diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 9cb013b9..bfba5aed 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -77,9 +77,10 @@ export interface InstallOptions { */ scaffoldConfig?: 'ensure' | 'offer' | 'skip' /** - * EQL generation to install: `'2'` (default, composite `eql_v2_encrypted`) - * or `'3'` (native `eql_v3.*` domain schema). v3 currently supports the - * direct install path only — not `--drizzle`, `--migration`, or `--latest`. + * EQL generation to install: `'3'` (default, native `eql_v3.*` domain + * schema) or `'2'` (composite `eql_v2_encrypted`). v3 currently supports the + * direct install path only — `--drizzle`, `--migration`, `--migrations-dir`, + * and `--latest` require an explicit `'2'`. */ eqlVersion?: string } @@ -204,7 +205,7 @@ export async function installCommand(options: InstallOptions) { // CIP-2985. const resolved = resolveProviderOptions(options, databaseUrl) - const eqlVersion: 2 | 3 = options.eqlVersion === '3' ? 3 : 2 + const eqlVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 // v3 supports the direct install path only. Explicit --drizzle/--migration // are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or @@ -640,23 +641,10 @@ export function validateInstallFlags(options: InstallOptions): string | null { return `Unknown \`--eql-version ${options.eqlVersion}\`. Supported values: 2, 3.` } - if (options.eqlVersion === '3') { - const incompatible = options.drizzle - ? '--drizzle' - : options.migration - ? '--migration' - : options.latest - ? '--latest' - : // --migrations-dir only feeds the Supabase v2 migration-file path; - // the v3 direct install would silently ignore it — reject instead. - options.migrationsDir !== undefined - ? '--migrations-dir' - : null - if (incompatible) { - return `\`--eql-version 3\` does not support \`${incompatible}\` yet — v3 currently installs via the direct path only.` - } - } - + // `--migration` / `--direct` / `--migrations-dir` require `--supabase`. Check + // this before the version gate below so a bare `--migration` still points at + // the missing `--supabase` (its more fundamental prerequisite) rather than + // the version. const subFlag = options.migration === true ? '--migration' @@ -670,6 +658,29 @@ export function validateInstallFlags(options: InstallOptions): string | null { return `\`${subFlag}\` requires \`--supabase\`. Re-run with \`eql install --supabase ${subFlag}\`.` } + // v3 is the default and installs via the direct path only. The Drizzle / + // Supabase-migration / `--latest` paths are v2-only, so they require an + // explicit `--eql-version 2` — otherwise they'd silently resolve to a v3 + // direct install that ignores what the user asked for. `--migrations-dir` + // only feeds the Supabase v2 migration-file path, so it's in the same bucket. + const resolvedVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 + if (resolvedVersion === 3) { + const v2OnlyFlag = options.drizzle + ? '--drizzle' + : options.migration + ? '--migration' + : options.latest + ? '--latest' + : options.migrationsDir !== undefined + ? '--migrations-dir' + : null + if (v2OnlyFlag) { + return options.eqlVersion === '3' + ? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.` + : `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).` + } + } + return null } diff --git a/packages/cli/src/commands/db/push.ts b/packages/cli/src/commands/db/push.ts index cba093f9..25b1caeb 100644 --- a/packages/cli/src/commands/db/push.ts +++ b/packages/cli/src/commands/db/push.ts @@ -5,6 +5,7 @@ import * as p from '@clack/prompts' import pg from 'pg' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadEncryptConfig, loadStashConfig } from '@/config/index.js' +import { EQLInstaller } from '@/installer/index.js' import { validateEncryptConfig } from './validate.js' /** @@ -43,6 +44,25 @@ export async function pushCommand(options: { const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl }) s.stop('Configuration loaded.') + // `db push` writes the encrypt config into `public.eql_v2_configuration` — + // a v2 + CipherStash Proxy artifact. EQL v3 has no configuration table + // (config lives in each column's `eql_v3.*` domain type) and nothing reads + // it, so on a v3-only database push has nothing to do and the table doesn't + // even exist. Detect that and exit cleanly instead of failing with a raw + // `relation "public.eql_v2_configuration" does not exist`. + const installer = new EQLInstaller({ databaseUrl: config.databaseUrl }) + const [v2Installed, v3Installed] = await Promise.all([ + installer.isInstalled({ eqlVersion: 2 }).catch(() => false), + installer.isInstalled({ eqlVersion: 3 }).catch(() => false), + ]) + if (v3Installed && !v2Installed) { + p.log.info( + "`stash db push` isn't needed under EQL v3 — encryption config lives in each column's `eql_v3.*` type, not a central config table. This command only applies to EQL v2 with CipherStash Proxy.", + ) + p.outro('Nothing to do.') + return + } + s.start(`Loading encrypt client from ${config.client}...`) const encryptConfig = await loadEncryptConfig(config.client) s.stop('Encrypt client loaded and validated.') diff --git a/packages/cli/src/commands/db/status.ts b/packages/cli/src/commands/db/status.ts index ebde1596..e5638dd3 100644 --- a/packages/cli/src/commands/db/status.ts +++ b/packages/cli/src/commands/db/status.ts @@ -91,7 +91,22 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) { ) } - // 3. Check for active encrypt config (proxy mode) + // 3. Encrypt configuration. + // + // `public.eql_v2_configuration` is a v2 + CipherStash Proxy artifact: the v2 + // install creates it and Proxy reads it. EQL v3 has no configuration table — + // encryption config lives in each column's `eql_v3.*` domain type — so on a + // v3-only install there's nothing to probe. Gate the check on v2 being + // installed; this also removes the old dead-end that told v3-only users to + // run `db push` (which neither creates that table nor applies to v3). + if (!installedV2) { + p.log.info( + "Encrypt config: carried in each column's `eql_v3.*` type (EQL v3 has no Proxy config table).", + ) + p.outro('Status check complete.') + return + } + s.start('Checking encrypt configuration...') const client = new pg.Client({ connectionString: config.databaseUrl }) @@ -110,22 +125,12 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) { `Active encrypt config: yes (${result.rowCount} active ${result.rowCount === 1 ? 'row' : 'rows'})`, ) } else { - p.log.info( - 'Active encrypt config: none (only needed for CipherStash Proxy)', - ) + p.log.info('Active encrypt config: none (only used by CipherStash Proxy)') } } catch (error) { s.stop('Configuration check failed.') - - // The table may not exist if push has never been run — that's fine 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 \`${runnerCommand(pm, 'stash db push')}\` to create it)`, - ) - } else { - p.log.error(`Failed to check encrypt configuration: ${message}`) - } + p.log.error(`Failed to check encrypt configuration: ${message}`) } finally { await client.end() } diff --git a/packages/cli/src/commands/db/upgrade.ts b/packages/cli/src/commands/db/upgrade.ts index 8e5db042..55aaeddc 100644 --- a/packages/cli/src/commands/db/upgrade.ts +++ b/packages/cli/src/commands/db/upgrade.ts @@ -9,7 +9,7 @@ export async function upgradeCommand(options: { excludeOperatorFamily?: boolean latest?: boolean databaseUrl?: string - /** EQL generation to upgrade: `'2'` (default) or `'3'`. */ + /** EQL generation to upgrade: `'3'` (default) or `'2'`. */ eqlVersion?: string }) { const pm = detectPackageManager() @@ -26,11 +26,15 @@ export async function upgradeCommand(options: { p.outro('Upgrade aborted.') process.exit(1) } - const eqlVersion: 2 | 3 = options.eqlVersion === '3' ? 3 : 2 + const eqlVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 if (eqlVersion === 3 && options.latest) { + // `--latest` is v2-only (no public v3 release artifacts exist yet). Since + // v3 is the default, tell the user how to reach the v2 latest upgrade. p.log.error( - '`--eql-version 3` does not support `--latest` — no public v3 release artifacts exist yet. Use the bundled upgrade.', + options.eqlVersion === '3' + ? '`--eql-version 3` does not support `--latest` — no public v3 release artifacts exist yet. Use the bundled upgrade.' + : '`--latest` requires EQL v2 — no public v3 release artifacts exist yet. Re-run with `--eql-version 2 --latest`, or drop `--latest` for the bundled v3 upgrade.', ) p.outro('Upgrade aborted.') process.exit(1) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index caa8ab66..84b2ceab 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -201,7 +201,7 @@ export async function backfillCommand(options: BackfillCommandOptions) { // time running a backfill that will fail on the first chunk. if (detectedCastAs === 'date' || detectedCastAs === 'timestamp') { p.log.warn( - `Column ${options.table}.${encryptedColumn} declares cast_as: '${detectedCastAs}', which protect-ffi does not currently support for encryption. The backfill will fail with "Cannot convert String to Date". Consider changing the schema to dataType: 'string' (or omitting dataType) and storing ISO date strings instead, then re-running \`stash db push\`.`, + `Column ${options.table}.${encryptedColumn} declares cast_as: '${detectedCastAs}', which protect-ffi does not currently support for encryption. The backfill will fail with "Cannot convert String to Date". Consider changing the schema to dataType: 'string' (or omitting dataType) and storing ISO date strings instead, then re-running the backfill.`, ) const proceed = await p.confirm({ message: 'Continue anyway?', diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts index d70ce759..3d8d4c81 100644 --- a/packages/cli/src/commands/encrypt/cutover.ts +++ b/packages/cli/src/commands/encrypt/cutover.ts @@ -81,7 +81,7 @@ export async function cutoverCommand(options: CutoverCommandOptions) { ) if (pending.rows[0]?.exists !== true) { p.log.error( - 'No pending EQL configuration. Update your schema to point at the encrypted column (drop the `_encrypted` suffix), then run `stash db push` to register the pending change before cutting over.', + 'No pending EQL configuration to cut over. Cutover operates on the EQL v2 + CipherStash Proxy config lifecycle — update your schema to point at the encrypted column (drop the `_encrypted` suffix) and register the pending change before cutting over.', ) exitCode = 1 return diff --git a/packages/cli/src/commands/encrypt/status.ts b/packages/cli/src/commands/encrypt/status.ts index cd75ffc6..8591fbbb 100644 --- a/packages/cli/src/commands/encrypt/status.ts +++ b/packages/cli/src/commands/encrypt/status.ts @@ -93,7 +93,7 @@ export async function statusCommand() { if (rows.length === 0) { p.log.info( - 'No encrypted columns yet. Run `stash db push` to register columns with EQL, then `stash encrypt backfill --table --column ` once your application is dual-writing.', + 'No encrypted columns yet. Declare an encrypted column in your schema and run the migration, then `stash encrypt backfill --table --column ` once your application is dual-writing.', ) p.outro('Nothing to show.') return diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index d44f760d..815c7f71 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -12,7 +12,6 @@ const baseCtx: SetupPromptContext = { handoff: 'claude-code', mode: 'implement', installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], - usesProxy: false, } describe('renderSetupPrompt — orient + route (implement mode)', () => { @@ -203,7 +202,6 @@ describe('renderSetupPrompt — plan mode (rollout, default)', () => { it('explicitly forbids mutating commands during planning', () => { const out = renderSetupPrompt(planCtx) expect(out).toContain('## What you must NOT do') - expect(out).toMatch(/db push/) expect(out).toMatch(/encrypt backfill/) expect(out).toMatch(/encrypt cutover/) expect(out).toMatch(/encrypt drop/) @@ -305,7 +303,7 @@ describe('renderSetupPrompt — plan mode (cutover)', () => { expect(out).toMatch(/path.*"migrate".*for every column/i) }) - it('covers backfill, schema rename + db push, cutover, read path, drop', () => { + it('covers backfill, schema rename, cutover, read path, drop', () => { const out = renderSetupPrompt(planCtx) expect(out).toMatch(/backfill/i) expect(out).toMatch(/cutover/i) @@ -382,180 +380,28 @@ describe('renderSetupPrompt — plan mode default when planStep is unset', () => }) }) -describe('renderSetupPrompt — usesProxy conditional', () => { - describe('implement mode with usesProxy: false (SDK-only)', () => { - it('drops db push step from add-new-column flow', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) - expect(out).not.toMatch(/5\.\s*Register the encryption config/) - // Step 5 should now be the wire-the-column step, not db push - expect(out).toMatch(/5\.\s*Wire the column through/) - }) - - it('drops register-pending-config step from rollout path', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) - // Should have only "Schema-add" as step 1, then "Dual-write" as step 2 - // (no "Register pending config" in between) - expect(out).toMatch(/1\.\s*\*\*Schema-add/) - expect(out).toMatch(/2\.\s*\*\*Dual-write/) - // Register pending config should not appear in the rollout section - const rolloutSection = out.substring( - out.indexOf('#### Encryption rollout'), - out.indexOf('⛔'), - ) - expect(rolloutSection).not.toMatch(/Register pending config/) - }) - - it('keeps encrypt cutover invocation and notes the pending-config workaround', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) - const cutoverSection = out.substring( - out.indexOf('#### Encryption cutover'), - ) - // Should mention encrypt cutover - expect(cutoverSection).toMatch(/encrypt cutover/) - // SDK-only setups still hit the pending-config gap, so the cutover - // step must call out the `db push` workaround for that error. - expect(cutoverSection).toMatch(/No pending EQL configuration/) - expect(cutoverSection).toMatch(/db push/) - }) - }) - - describe('implement mode with usesProxy: true (Proxy)', () => { - it('includes db push step in add-new-column flow', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) - expect(out).toMatch(/5\.\s*Register the encryption config.*db push/) - expect(out).toMatch(/6\.\s*\*\*If db push wrote pending\*\*.*db activate/) - }) - - it('includes register-pending-config step in rollout path', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) - expect(out).toMatch(/2\.\s*\*\*Register pending config.*db push/) - expect(out).toMatch(/3\.\s*\*\*Dual-write/) - }) - - it('includes full db push in cutover step', () => { - const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) - const cutoverSection = out.substring( - out.indexOf('#### Encryption cutover'), - ) - expect(cutoverSection).toMatch(/5\.\s*\*\*Switch the schema and re-push/) - expect(cutoverSection).toMatch(/Run.*db push.*again/) - }) - }) - - describe('plan mode (rollout) with usesProxy conditional', () => { - it('mentions db push in rollout plan summary when usesProxy: true', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'rollout', - usesProxy: true, - }) - expect(out).toMatch( - /Encryption rollout.*dual-write code, and.*db push.*writes pending/, - ) - }) - - it('notes db push as Proxy-only in rollout plan summary when usesProxy: false', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'rollout', - usesProxy: false, - }) - expect(out).toMatch( - /Encryption rollout.*dual-write code.*plus.*db push.*Proxy users only/, - ) - }) - - it('includes db push in rollout PR contents when usesProxy: true', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'rollout', - usesProxy: true, - }) - expect(out).toMatch(/schema-add.*db push.*pending.*dual-write code/) - }) - - it('drops db push from rollout PR contents when usesProxy: false', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'rollout', - usesProxy: false, - }) - expect(out).toMatch(/schema-add.*dual-write code/) - // Should not mention "db push (pending)" in the rollout PR contents - const prSection = out.substring( - out.indexOf('migrate columns: what the rollout PR contains'), - ) - expect(prSection).not.toMatch(/db push.*pending/) - }) - }) - - describe('plan mode (cutover) with usesProxy conditional', () => { - it('includes db push in schema-rename when usesProxy: true', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'cutover', - usesProxy: true, - }) - expect(out).toMatch(/Schema rename and re-push/) - expect(out).toMatch(/db push.*registers the renamed/) - }) - - it('separates schema rename from db push when usesProxy: false', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'cutover', - usesProxy: false, - }) - expect(out).toMatch(/\*\*Schema rename\.\*\*.*original column/) - expect(out).toMatch(/Proxy users only.*db push/) - }) - - it('notes db push as Proxy-only in prose when usesProxy: false', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'cutover', - usesProxy: false, - }) - expect(out).toMatch(/schema-edit step.*exact rename pattern/) - expect(out).not.toMatch(/schema-edit.*db push.*step/i) - }) - }) - - describe('plan mode (complete) with usesProxy conditional', () => { - it('includes db push steps in full lifecycle when usesProxy: true', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'complete', - usesProxy: true, - }) - expect(out).toMatch(/db push.*backfill.*schema rename.*db push.*cutover/) - }) - - it('drops both db push mentions from full lifecycle when usesProxy: false', () => { - const out = renderSetupPrompt({ - ...baseCtx, - mode: 'plan', - planStep: 'complete', - usesProxy: false, - }) - expect(out).toMatch( - /schema-add.*dual-write code.*backfill.*schema rename/, - ) - // The lifecycle line should not have "db push" twice - const migrateSection = out.substring( - out.indexOf('**Migrate existing columns**'), - ) - const firstLine = migrateSection.split('\n')[0] - const dbPushCount = (firstLine.match(/db push/g) || []).length - expect(dbPushCount).toBe(0) - }) +describe('renderSetupPrompt — no db push recommendations', () => { + // `db push` / `eql_v2_configuration` is a v2 + CipherStash Proxy artifact and + // is redundant under EQL v3 (the default). The setup prompt no longer steers + // the agent toward it in any mode. + it('omits db push from the add-new-column and cutover flows (implement mode)', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).not.toMatch(/db push/) + expect(out).not.toMatch(/Register the encryption config/) + // The add-new-column flow goes straight from apply → wire the column. + expect(out).toMatch(/5\.\s*Wire the column through/) + // The rollout path is schema-add → dual-write, with no push step between. + expect(out).toMatch(/1\.\s*\*\*Schema-add/) + expect(out).toMatch(/2\.\s*\*\*Dual-write/) + // Cutover is still covered, just without a db push workaround note. + const cutoverSection = out.substring(out.indexOf('#### Encryption cutover')) + expect(cutoverSection).toMatch(/encrypt cutover/) + }) + + it('omits db push from every plan-mode template', () => { + for (const planStep of ['rollout', 'cutover', 'complete'] as const) { + const out = renderSetupPrompt({ ...baseCtx, mode: 'plan', planStep }) + expect(out).not.toMatch(/db push/) + } }) }) diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 98b40c58..1e5a3dfc 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -32,8 +32,6 @@ export interface SetupPromptContext { * to `'rollout'` when plan mode is invoked without explicit state — that * matches a fresh project where there are no recorded events yet. */ planStep?: PlanStep - /** Whether the user runs CipherStash Proxy. False = SDK-only (no `stash db push` in default flows). True = prompts include push steps. Captured in stash init; persisted to .cipherstash/context.json. */ - usesProxy: boolean } interface MigrationCommands { @@ -251,17 +249,8 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", `3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`, `4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`, - ...(ctx.usesProxy - ? [ - `5. Register the encryption config — \`${cli} db push\`. If the project has no active EQL config yet (first encrypted column ever), this writes directly to active and you can skip step 6. If an active config already exists, push writes \`pending\` and prints a next-step note.`, - `6. **If db push wrote pending**, promote it to active — \`${cli} db activate\`. (Use \`${cli} db activate\` here because no rename is needed; \`${cli} encrypt cutover\` is reserved for the migrate-existing-column flow.)`, - '7. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', - '8. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', - ] - : [ - '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', - '6. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', - ]), + '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', + '6. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', '', '### Migrate an existing column to encrypted', '', @@ -272,14 +261,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '#### Encryption rollout — what lands before the deploy', '', `1. **Schema-add.** Add a \`_encrypted\` twin column (nullable \`jsonb\`) alongside the existing plaintext column in the user's real schema file. Generate and apply the schema migration. **If this is the first encrypted column in the project, configure the bundler exclusion now** — see the snippets in the previous section. Without it, importing the encryption client at backfill time will crash.`, - ...(ctx.usesProxy - ? [ - `2. **Register pending config** — \`${cli} db push\`. With an existing active config, this writes the new column-set as \`pending\`. Cutover (later) will promote it. (If this is the very first push for the project, db push writes active directly — fine, the rest of the flow still works.)`, - `3. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, - ] - : [ - `2. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, - ]), + `2. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, '', `⛔ **Deploy gate.** Stop here. The application must be running this code in production — the deployed environment that owns the database — before backfill is safe to run. "Live on the user's laptop" or "live in CI" does not count. After the user deploys, tell them to run`, '', @@ -289,21 +271,11 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '', '#### Encryption cutover — after dual-writes are live', '', - ...(ctx.usesProxy - ? [ - `4. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `5. **Switch the schema and re-push, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Run \`${cli} db push\` again — pending now reflects the renamed shape. Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`) and promotes pending → active.`, - '6. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw `eql_v2_encrypted` payloads to end users. The integration skill has the exact API.', - '7. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', - `8. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, - ] - : [ - `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `4. **Switch the schema, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`) and promotes pending → active. *Known gap: cutover currently needs a pending EQL config. If it fails with "No pending EQL configuration", run \`${cli} db push\` once before cutover to register the pending change — this applies even on SDK-only setups that otherwise never need \`db push\`.*`, - '5. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw `eql_v2_encrypted` payloads to end users. The integration skill has the exact API.', - '6. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', - `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, - ]), + `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, + `4. **Switch the schema, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`).`, + '5. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw encrypted payloads to end users. The integration skill has the exact API.', + '6. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', + `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, '', 'Recovery: if the user reports that backfill ran *before* the dual-write code was actually live, drift is expected (rows written during the backfill window land in plaintext only). Re-run with `--force` to encrypt every plaintext row regardless of current state.', '', @@ -341,13 +313,13 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { * Render the planning action prompt. * * Plan-mode tells the agent its task is to produce a reviewable plan file - * at `.cipherstash/plan.md` — no schema edits, no migrations, no `db push`, - * no `encrypt *` mutations during this phase. Read-only inspection + * at `.cipherstash/plan.md` — no schema edits, no migrations, no + * `encrypt *` mutations during this phase. Read-only inspection * (`stash status`, `stash eql status`, schema grep, file reads) is fine. * * Dispatches by `ctx.planStep`: * - * `'rollout'` (default) — schema-add + dual-write code + db push. + * `'rollout'` (default) — schema-add + dual-write code. * Plan stops at the deploy gate. * `'cutover'` — backfill + cutover + drop. Pre-condition: * `dual_writing` recorded for the targeted @@ -409,7 +381,7 @@ function planSharedNotDoBlock(ctx: SetupPromptContext): string[] { 'Edit schema files, application code, or migration files. The plan describes future changes — it does not perform them.', ), bullet( - `Run \`${cli} db push\`, \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, \`${cli} encrypt drop\`, \`${cli} db activate\`, or any other state-mutating command.`, + `Run \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, \`${cli} encrypt drop\`, or any other state-mutating command.`, ), bullet( 'Run schema migrations (`drizzle-kit migrate`, `supabase migration up`, `prisma migrate`, etc.).', @@ -480,9 +452,7 @@ function renderRolloutPlanPrompt(ctx: SetupPromptContext): string { '**Add a new encrypted column** — single deploy, no rollout/cutover split. Declared encrypted from the start.', ), bullet( - ctx.usesProxy - ? '**Encryption rollout for an existing column** — the encrypted twin column, the application-side dual-write code, and `stash db push` (writes pending). All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.' - : '**Encryption rollout for an existing column** — the encrypted twin column and the application-side dual-write code (plus `stash db push` for Proxy users only). All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.', + '**Encryption rollout for an existing column** — the encrypted twin column and the application-side dual-write code. All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.', ), '', 'Converting a populated column in place is **not** supported — any "just swap the type" approach corrupts data. If the user asks for that, the plan must explain why and route them to the encryption-rollout flow.', @@ -508,9 +478,7 @@ function renderRolloutPlanPrompt(ctx: SetupPromptContext): string { 'Which path applies per column (additive new column or encryption-rollout for an existing one). Justify briefly.', ), bullet( - ctx.usesProxy - ? 'For migrate columns: what the rollout PR contains — schema-add, `db push` (pending), and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.' - : 'For migrate columns: what the rollout PR contains — schema-add and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.', + 'For migrate columns: what the rollout PR contains — schema-add and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.', ), bullet( `Project-specific risks. Common ones: bundler exclusion not yet configured (Next.js / webpack / Vite), top-level-await in the placeholder encryption client breaks non-Next contexts, existing partial CipherStash state (run \`${cli} eql status\` and note any pre-existing encrypted columns or pending configs).`, @@ -552,7 +520,7 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { ), `\`${cli} plan\` detected that dual-writes are recorded as live in \`cs_migrations\` for at least one column. Your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\` covering the **encryption cutover** — backfilling historical rows, switching reads through the encryption client, and dropping the old plaintext column. **Do not** make code or schema changes here; do not run mutating CLI commands. Read-only inspection is encouraged.`, '', - 'The encryption rollout (schema-add + dual-write code + `db push`) is assumed already deployed to production. If the prose ends up describing dual-write code edits, you are off-scope — re-anchor on what cutover-step work remains.', + 'The encryption rollout (schema-add + dual-write code) is assumed already deployed to production. If the prose ends up describing dual-write code edits, you are off-scope — re-anchor on what cutover-step work remains.', '', ...planSharedSetupBlock(ctx), '## What this plan covers', @@ -563,19 +531,10 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { '**Backfill.** Encrypt the historical rows that pre-date the rollout deploy. Resumable; chunked; SIGINT-safe.', ), bullet( - ctx.usesProxy - ? '**Schema rename and re-push.** Update the schema declaration to put the encrypted form under its final column name; `stash db push` registers the renamed pending config.' - : '**Schema rename.** Update the schema declaration so the original column points at the encrypted type.', + '**Schema rename.** Update the schema declaration so the original column points at the encrypted type.', ), - ...(ctx.usesProxy - ? [] - : [ - bullet( - '*Proxy users only*: after the schema rename, also run `stash db push` to register the renamed pending config.', - ), - ]), bullet( - '**Cutover.** A single transaction renames `` → `_plaintext`, `_encrypted` → ``, and promotes the pending EQL config to active.', + '**Cutover.** A single transaction renames `` → `_plaintext` and `_encrypted` → ``.', ), bullet( '**Read path.** Application reads of `` now return ciphertext until the read path decrypts via the encryption client. The plan must specify what changes per read site.', @@ -607,9 +566,7 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { ' encrypt backfill` invocation with concrete `--table` / `--column` values.', ), bullet( - ctx.usesProxy - ? 'The schema-edit + `db push` step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).' - : 'The schema-edit step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).', + 'The schema-edit step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).', ), bullet( 'The cutover invocation per column: `' + @@ -671,9 +628,7 @@ function renderCompletePlanPrompt(ctx: SetupPromptContext): string { '**Add new encrypted columns** — declared encrypted from the start; single-deploy.', ), bullet( - ctx.usesProxy - ? '**Migrate existing columns** — schema-add → dual-write code → `db push` → backfill → schema rename → `db push` → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.' - : '**Migrate existing columns** — schema-add → dual-write code → backfill → schema rename → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.', + '**Migrate existing columns** — schema-add → dual-write code → backfill → schema rename → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.', ), '', '## Your task: produce the complete-rollout plan file', diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 7f8b98c4..66d14e52 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -168,7 +168,6 @@ export function buildSetupPromptContext( mode: state.mode ?? 'implement', installedSkills, planStep: state.planStep, - usesProxy: state.usesProxy ?? false, } } diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index f054a423..14f1cec8 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -82,6 +82,11 @@ export const installEqlStep: InitStep = { supabase: supabase || undefined, drizzle: drizzle || undefined, databaseUrl: state.databaseUrl, + // EQL v3 is the default, but the Drizzle migration path is v2-only, so + // pin v2 when we're driving the Drizzle flow — otherwise the install + // would reject `--drizzle` under the v3 default. Supabase and plain + // Postgres projects take the v3 default (direct install). + eqlVersion: drizzle ? '2' : undefined, // init passes a resolved URL to avoid re-prompting, but still wants a // config scaffolded — this is NOT a one-shot `--database-url` run. scaffoldConfig: 'ensure', diff --git a/packages/cli/src/commands/status/quest.ts b/packages/cli/src/commands/status/quest.ts index 57811d9f..0dabf8d3 100644 --- a/packages/cli/src/commands/status/quest.ts +++ b/packages/cli/src/commands/status/quest.ts @@ -221,7 +221,7 @@ function nextMoveFor( ): string { if (path === 'new') { if (doneCount === 0) { - return `Declare the encrypted column in your schema and run the migration, then \`${cli} db push\`.` + return 'Declare the encrypted column in your schema and run the migration.' } return `Promote the pending EQL config — \`${cli} db activate\`.` } diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 5f8158ff..55e63360 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -17,6 +17,15 @@ const EQL_V3_INTERNAL_SCHEMA_NAME = 'eql_v3_internal' */ export type EqlVersion = 2 | 3 +/** + * The generation `stash eql install` / `eql upgrade` target when the user + * doesn't pass `--eql-version`. v3 (native `eql_v3.*` domain types) is the + * current default; v2 is opt-in via `--eql-version 2` and is required for the + * Drizzle / Supabase-migration / `--latest` paths, which v3 doesn't support + * yet. + */ +export const DEFAULT_EQL_VERSION: EqlVersion = 3 + function schemaNameFor(eqlVersion: EqlVersion): string { return eqlVersion === 3 ? EQL_V3_SCHEMA_NAME : EQL_SCHEMA_NAME } @@ -200,11 +209,11 @@ export class EQLInstaller { /** * Check whether the EQL extension is installed by looking for its schema - * (`eql_v2` by default, `eql_v3` when `eqlVersion: 3`). + * (`eql_v3` by default, `eql_v2` when `eqlVersion: 2`). */ async isInstalled(options?: { eqlVersion?: EqlVersion }): Promise { const client = new pg.Client({ connectionString: this.databaseUrl }) - const eqlVersion = options?.eqlVersion ?? 2 + const eqlVersion = options?.eqlVersion ?? DEFAULT_EQL_VERSION try { await client.connect() @@ -244,7 +253,7 @@ export class EQLInstaller { async getInstalledVersion(options?: { eqlVersion?: EqlVersion }): Promise { - const schemaName = schemaNameFor(options?.eqlVersion ?? 2) + const schemaName = schemaNameFor(options?.eqlVersion ?? DEFAULT_EQL_VERSION) const client = new pg.Client({ connectionString: this.databaseUrl }) try { @@ -300,7 +309,11 @@ export class EQLInstaller { latest?: boolean eqlVersion?: EqlVersion }): Promise { - const { supabase = false, latest = false, eqlVersion = 2 } = options ?? {} + const { + supabase = false, + latest = false, + eqlVersion = DEFAULT_EQL_VERSION, + } = options ?? {} const excludeOperatorFamily = options?.excludeOperatorFamily || supabase if (latest && eqlVersion === 3) { @@ -441,7 +454,7 @@ function resolveBundledFilename(options: { supabase: boolean eqlVersion?: EqlVersion }): string { - if ((options.eqlVersion ?? 2) === 3) { + if ((options.eqlVersion ?? DEFAULT_EQL_VERSION) === 3) { if (options.supabase || options.excludeOperatorFamily) return 'cipherstash-encrypt-v3-supabase.sql' return 'cipherstash-encrypt-v3.sql' @@ -465,7 +478,7 @@ export function loadBundledEqlSql( const filename = resolveBundledFilename({ excludeOperatorFamily: options.excludeOperatorFamily ?? false, supabase: options.supabase ?? false, - eqlVersion: options.eqlVersion ?? 2, + eqlVersion: options.eqlVersion ?? DEFAULT_EQL_VERSION, }) try { From 8502cdf2ad0d9609461009636b4313daeb4aad29 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 00:04:20 +1000 Subject: [PATCH 2/3] fix(cli): address code-review findings on the EQL-v3-default PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status.ts (critical, self-inflicted regression): `installedV2` used bare `isInstalled()` / `getInstalledVersion()`, which now default to v3 after the DEFAULT_EQL_VERSION flip — so on a v2-only DB status wrongly reported "EQL is not installed", and on a v3 DB it mislabelled the version and re-hit the eql_v2_configuration dead-end. Pass `{ eqlVersion: 2 }` explicitly. - push.ts: replace the two `isInstalled` probes (3 connections, missed the fresh-DB case, diverged when the schema exists without the table, and swallowed transient errors via `.catch(() => false)`) with a single `to_regclass('public.eql_v2_configuration')` check on the connection push already opens. One connection; covers v3-only, fresh, and partial installs. - Centralize the default-version resolution in `resolveEqlVersion()` next to DEFAULT_EQL_VERSION and use it in install.ts (both sites) + upgrade.ts, so the default lives in one place instead of three hand-inlined `=== '2' ? 2 : 3`. - Document the Supabase-init behavior change (now a v3 direct install rather than the v2 migration-file flow) in the changeset. - Add resolveEqlVersion unit coverage. 398 unit + 55 e2e green. --- .changeset/stash-cli-eql-v3-default.md | 2 +- .../src/__tests__/supabase-migration.test.ts | 18 ++++++++- packages/cli/src/commands/db/install.ts | 5 ++- packages/cli/src/commands/db/push.ts | 38 +++++++++---------- packages/cli/src/commands/db/status.ts | 6 ++- packages/cli/src/commands/db/upgrade.ts | 4 +- packages/cli/src/installer/index.ts | 12 ++++++ 7 files changed, 57 insertions(+), 28 deletions(-) diff --git a/.changeset/stash-cli-eql-v3-default.md b/.changeset/stash-cli-eql-v3-default.md index ee9b3a58..b19eecc5 100644 --- a/.changeset/stash-cli-eql-v3-default.md +++ b/.changeset/stash-cli-eql-v3-default.md @@ -4,7 +4,7 @@ Default EQL to v3 and stop the CLI recommending `stash db push` (#585). -- **EQL v3 is now the default.** `stash eql install` and `stash eql upgrade` target v3 (the native `eql_v3.*` domain schema) without `--eql-version 3`. The v2-only paths — `--drizzle`, `--migration`, `--migrations-dir`, and `--latest` — now require an explicit `--eql-version 2` and error with clear guidance otherwise (v3 installs via the direct path only). `stash init` pins v2 automatically when it drives the Drizzle migration flow. +- **EQL v3 is now the default.** `stash eql install` and `stash eql upgrade` target v3 (the native `eql_v3.*` domain schema) without `--eql-version 3`. The v2-only paths — `--drizzle`, `--migration`, `--migrations-dir`, and `--latest` — now require an explicit `--eql-version 2` and error with clear guidance otherwise (v3 installs via the direct path only). `stash init` pins v2 automatically when it drives the Drizzle migration flow. **Note:** for a Supabase project, `stash init` now runs a v3 direct install rather than offering the v2 migration-file flow; run `stash eql install --supabase --migration --eql-version 2` if you want a checked-in migration file. - **`stash db push` is no longer recommended in CLI output.** `db push` writes the `public.eql_v2_configuration` table, which is a v2 + CipherStash Proxy artifact — EQL v3 has no configuration table (config lives in each column's `eql_v3.*` type) and nothing in the v3 stack reads it. The push recommendations are removed from `eql status`, the help banner, and the init/plan/cutover guidance. `db push` (and `db activate`) remain available for EQL v2 + Proxy users; they're now labelled as such. - **`eql status` is v3-aware.** On a v3-only database it reports that encrypt config lives in the column types instead of hitting a "table not found" dead-end that told users to run `db push` (which neither creates that table nor applies to v3). - **`stash db push` guards a v3-only database** with a clear "not needed under EQL v3" message instead of a raw `relation "public.eql_v2_configuration" does not exist` error. diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts index 311787e5..2f3a62bf 100644 --- a/packages/cli/src/__tests__/supabase-migration.test.ts +++ b/packages/cli/src/__tests__/supabase-migration.test.ts @@ -12,7 +12,11 @@ import { SUPABASE_EQL_MIGRATION_FILENAME, writeSupabaseEqlMigration, } from '../commands/db/supabase-migration.js' -import { SUPABASE_PERMISSIONS_SQL } from '../installer/index.js' +import { + DEFAULT_EQL_VERSION, + resolveEqlVersion, + SUPABASE_PERMISSIONS_SQL, +} from '../installer/index.js' /** * Generate the migration header for testing purposes. @@ -203,6 +207,18 @@ describe('writeSupabaseEqlMigration', () => { }) }) +describe('resolveEqlVersion', () => { + it('defaults a missing version to DEFAULT_EQL_VERSION (v3)', () => { + expect(DEFAULT_EQL_VERSION).toBe(3) + expect(resolveEqlVersion(undefined)).toBe(3) + }) + + it('resolves explicit strings to their generation', () => { + expect(resolveEqlVersion('2')).toBe(2) + expect(resolveEqlVersion('3')).toBe(3) + }) +}) + describe('validateInstallFlags', () => { it('returns null for an empty options object', () => { expect(validateInstallFlags({})).toBeNull() diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index bfba5aed..adcb5954 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -16,6 +16,7 @@ import { downloadEqlSql, EQLInstaller, loadBundledEqlSql, + resolveEqlVersion, } from '@/installer/index.js' import { ensureEncryptionClient } from './client-scaffold.js' import { offerStashConfig } from './config-scaffold.js' @@ -205,7 +206,7 @@ export async function installCommand(options: InstallOptions) { // CIP-2985. const resolved = resolveProviderOptions(options, databaseUrl) - const eqlVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 + const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion) // v3 supports the direct install path only. Explicit --drizzle/--migration // are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or @@ -663,7 +664,7 @@ export function validateInstallFlags(options: InstallOptions): string | null { // explicit `--eql-version 2` — otherwise they'd silently resolve to a v3 // direct install that ignores what the user asked for. `--migrations-dir` // only feeds the Supabase v2 migration-file path, so it's in the same bucket. - const resolvedVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 + const resolvedVersion = resolveEqlVersion(options.eqlVersion) if (resolvedVersion === 3) { const v2OnlyFlag = options.drizzle ? '--drizzle' diff --git a/packages/cli/src/commands/db/push.ts b/packages/cli/src/commands/db/push.ts index 25b1caeb..21602be0 100644 --- a/packages/cli/src/commands/db/push.ts +++ b/packages/cli/src/commands/db/push.ts @@ -5,7 +5,6 @@ import * as p from '@clack/prompts' import pg from 'pg' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadEncryptConfig, loadStashConfig } from '@/config/index.js' -import { EQLInstaller } from '@/installer/index.js' import { validateEncryptConfig } from './validate.js' /** @@ -44,25 +43,6 @@ export async function pushCommand(options: { const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl }) s.stop('Configuration loaded.') - // `db push` writes the encrypt config into `public.eql_v2_configuration` — - // a v2 + CipherStash Proxy artifact. EQL v3 has no configuration table - // (config lives in each column's `eql_v3.*` domain type) and nothing reads - // it, so on a v3-only database push has nothing to do and the table doesn't - // even exist. Detect that and exit cleanly instead of failing with a raw - // `relation "public.eql_v2_configuration" does not exist`. - const installer = new EQLInstaller({ databaseUrl: config.databaseUrl }) - const [v2Installed, v3Installed] = await Promise.all([ - installer.isInstalled({ eqlVersion: 2 }).catch(() => false), - installer.isInstalled({ eqlVersion: 3 }).catch(() => false), - ]) - if (v3Installed && !v2Installed) { - p.log.info( - "`stash db push` isn't needed under EQL v3 — encryption config lives in each column's `eql_v3.*` type, not a central config table. This command only applies to EQL v2 with CipherStash Proxy.", - ) - p.outro('Nothing to do.') - return - } - s.start(`Loading encrypt client from ${config.client}...`) const encryptConfig = await loadEncryptConfig(config.client) s.stop('Encrypt client loaded and validated.') @@ -103,6 +83,24 @@ export async function pushCommand(options: { await client.connect() s.stop('Connected to Postgres.') + // `db push` writes `public.eql_v2_configuration` — a v2 + CipherStash Proxy + // artifact. EQL v3 has no configuration table (config lives in each + // column's `eql_v3.*` domain type) and nothing reads it, so if the table is + // absent — a v3-only database, or a database with no EQL installed at all — + // there's nothing to push. Check the table directly (one query on the + // connection we already hold) and exit cleanly instead of failing later + // with a raw `relation "public.eql_v2_configuration" does not exist`. + const tableResult = await client.query<{ reg: string | null }>( + "SELECT to_regclass('public.eql_v2_configuration') AS reg", + ) + if (tableResult.rows[0]?.reg == null) { + p.log.info( + "`stash db push` writes the EQL v2 configuration table, which this database doesn't have. It only applies to EQL v2 with CipherStash Proxy — under EQL v3 (the default) encryption config lives in each column's `eql_v3.*` type, so there's nothing to push. For a v2 + Proxy setup, run `stash eql install --eql-version 2` first.", + ) + p.outro('Nothing to do.') + return + } + s.start('Checking public.eql_v2_configuration state...') const activeResult = await client.query<{ exists: boolean }>( "SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'active') AS exists", diff --git a/packages/cli/src/commands/db/status.ts b/packages/cli/src/commands/db/status.ts index e5638dd3..20fe1986 100644 --- a/packages/cli/src/commands/db/status.ts +++ b/packages/cli/src/commands/db/status.ts @@ -29,9 +29,11 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) { let versionV3: string | null try { - installedV2 = await installer.isInstalled() + installedV2 = await installer.isInstalled({ eqlVersion: 2 }) installedV3 = await installer.isInstalled({ eqlVersion: 3 }) - versionV2 = installedV2 ? await installer.getInstalledVersion() : null + versionV2 = installedV2 + ? await installer.getInstalledVersion({ eqlVersion: 2 }) + : null versionV3 = installedV3 ? await installer.getInstalledVersion({ eqlVersion: 3 }) : null diff --git a/packages/cli/src/commands/db/upgrade.ts b/packages/cli/src/commands/db/upgrade.ts index 55aaeddc..b6292677 100644 --- a/packages/cli/src/commands/db/upgrade.ts +++ b/packages/cli/src/commands/db/upgrade.ts @@ -1,7 +1,7 @@ import * as p from '@clack/prompts' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadStashConfig } from '@/config/index.js' -import { EQLInstaller } from '@/installer/index.js' +import { EQLInstaller, resolveEqlVersion } from '@/installer/index.js' export async function upgradeCommand(options: { dryRun?: boolean @@ -26,7 +26,7 @@ export async function upgradeCommand(options: { p.outro('Upgrade aborted.') process.exit(1) } - const eqlVersion: 2 | 3 = options.eqlVersion === '2' ? 2 : 3 + const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion) if (eqlVersion === 3 && options.latest) { // `--latest` is v2-only (no public v3 release artifacts exist yet). Since diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 55e63360..13ea8f72 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -26,6 +26,18 @@ export type EqlVersion = 2 | 3 */ export const DEFAULT_EQL_VERSION: EqlVersion = 3 +/** + * Resolve the `--eql-version` CLI string (`'2'` / `'3'` / `undefined`) to a + * concrete {@link EqlVersion}, applying {@link DEFAULT_EQL_VERSION} for anything + * that isn't an explicit `'2'`. The single authority for "which generation does + * a bare invocation target", shared by `eql install` / `eql upgrade` so the + * default lives in one place rather than being re-inlined as `=== '2' ? 2 : 3`. + * Assumes the value has already been validated (see `validateInstallFlags`). + */ +export function resolveEqlVersion(eqlVersion?: string): EqlVersion { + return eqlVersion === '2' ? 2 : DEFAULT_EQL_VERSION +} + function schemaNameFor(eqlVersion: EqlVersion): string { return eqlVersion === 3 ? EQL_V3_SCHEMA_NAME : EQL_SCHEMA_NAME } From 5d13d2b7f1933c33c1fc1c846664a096fd14f6b7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 00:56:48 +1000 Subject: [PATCH 3/3] fix(cli): pin EQL v2 in the Supabase migration-file generator (PR #586 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeSupabaseEqlMigration called loadBundledEqlSql without an eqlVersion, so after the DEFAULT_EQL_VERSION flip to v3 it emitted cipherstash-encrypt-v3- supabase.sql into a migration that still appends the v2 SUPABASE_PERMISSIONS_SQL — a mismatched v3-body/v2-perms file. The Supabase migration-file flow is v2-only (v3 has no migration-file path), so pin eqlVersion: 2 explicitly. The existing test passed despite the bug because the header + permissions text mention eql_v2 regardless of the body; strengthened it to assert `eql_v2_encrypted` is present and no `eql_v3` leaks in. Caught by Copilot on the PR. --- .../cli/src/__tests__/supabase-migration.test.ts | 9 +++++++-- packages/cli/src/commands/db/supabase-migration.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts index 2f3a62bf..f5709204 100644 --- a/packages/cli/src/__tests__/supabase-migration.test.ts +++ b/packages/cli/src/__tests__/supabase-migration.test.ts @@ -144,8 +144,13 @@ describe('writeSupabaseEqlMigration', () => { /-- CipherStash EQL — installed by `(npx|bunx|pnpm dlx|yarn dlx) stash eql install --supabase --migration`/, ) expect(contents).toContain('CipherStash') - // EQL SQL body — the bundled supabase variant defines eql_v2. - expect(contents).toContain('eql_v2') + // EQL SQL body — the bundled supabase variant defines eql_v2. The + // migration-file flow is v2-only, so the body must be the v2 generation: + // assert the v2 composite type is present and no v3 schema leaks in. (A + // bare `toContain('eql_v2')` isn't enough — the header + permissions + // mention eql_v2 even if the body were v3.) + expect(contents).toContain('eql_v2_encrypted') + expect(contents).not.toContain('eql_v3') // Permissions block (single source of truth). expect(contents).toContain(SUPABASE_PERMISSIONS_SQL.trim()) }) diff --git a/packages/cli/src/commands/db/supabase-migration.ts b/packages/cli/src/commands/db/supabase-migration.ts index e1400b46..c541a8a7 100644 --- a/packages/cli/src/commands/db/supabase-migration.ts +++ b/packages/cli/src/commands/db/supabase-migration.ts @@ -99,11 +99,15 @@ export async function writeSupabaseEqlMigration( ) } - // The runtime install always uses `cipherstash-encrypt-supabase.sql` for - // Supabase, which is the no-operator-family variant. We pass both flags so - // intent is explicit and `loadBundledEqlSql` resolves the supabase file - // even if the underlying selection rules ever change. + // The Supabase migration-file flow is v2-only (v3 has no migration-file + // path), so pin `eqlVersion: 2` explicitly — otherwise it would follow the + // v3 default and emit `cipherstash-encrypt-v3-supabase.sql` alongside the v2 + // `SUPABASE_PERMISSIONS_SQL` below, producing a mismatched migration. We also + // pass both supabase/no-operator-family flags so intent is explicit and + // `loadBundledEqlSql` resolves the supabase file even if selection rules ever + // change. const eqlSql = loadBundledEqlSql({ + eqlVersion: 2, supabase: true, excludeOperatorFamily: excludeOperatorFamily || true, })