From c8726cd5efe88dd0b94a9b8972dcb49da35008a1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 20 Jul 2026 12:38:24 +1000 Subject: [PATCH 1/2] fix(cli): `stash init --drizzle` installs EQL v3, not v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Drizzle init flow pinned `eqlVersion: '2'` when calling `installCommand`, because `eql install --drizzle` — the only migration-generating install path at the time — was v2-only and rejects `--drizzle` under the v3 default. That made `stash init --drizzle` the single flow that provisions a v2 database. A bare `stash eql install`, and init for every other integration, already default to v3 (`resolveEqlVersion`, registry `default: '3'`). So every new Drizzle customer was silently added to the v2 install base the team plans to retire. It was also internally inconsistent: init copies the `stash-drizzle` skill into the same project, and that skill documents the v3 surface (`@cipherstash/stack-drizzle/v3`, `types.*` domains, `EncryptionV3`, `public.eql_v3_*` column types). The user's agent would author v3 code against the v2 database init had just provisioned. `stash eql migration --drizzle` (#691) closes the gap that forced the pin: v3 SQL, still migration-first. Init's Drizzle flow now routes through it, so the install keeps landing in the project's Drizzle migration history while emitting v3. The generated migration also carries the `cs_migrations` tracking schema, so one `drizzle-kit migrate` covers everything `stash encrypt …` needs. `eql install`'s config/client scaffolding isn't part of `eql migration`, so the branch does it directly (`offerStashConfig({ ensure: true })` + `ensureEncryptionClient`) to keep init's contract identical — without it, init would finish with no `stash.config.ts` and every downstream command would dead-end (#581). A missing or misconfigured `drizzle-kit` now degrades to "EQL not installed" with a pointer to `stash eql migration --drizzle`, rather than the old v2 path's `process.exit(1)` mid-init. Also: - `eqlMigrationCommand` gains `embedded` to suppress its intro/outro and `printNextSteps()` when run as an init step, so the user doesn't get two competing "what next" blocks. - `eql install --drizzle`'s rejection message now points at the v3 alternative instead of only suggesting `--eql-version 2`. - Updated `skills/stash-cli` and `skills/stash-drizzle`, which both documented the v2 pin as current behaviour. --- .changeset/init-drizzle-eql-v3.md | 26 ++++ packages/cli/src/commands/db/install.ts | 11 +- packages/cli/src/commands/eql/migration.ts | 25 +++- .../init/steps/__tests__/install-eql.test.ts | 128 +++++++++++++++--- .../src/commands/init/steps/install-eql.ts | 69 ++++++++-- packages/cli/src/commands/init/types.ts | 2 +- skills/stash-cli/SKILL.md | 2 +- skills/stash-drizzle/SKILL.md | 2 +- 8 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 .changeset/init-drizzle-eql-v3.md diff --git a/.changeset/init-drizzle-eql-v3.md b/.changeset/init-drizzle-eql-v3.md new file mode 100644 index 000000000..fac150347 --- /dev/null +++ b/.changeset/init-drizzle-eql-v3.md @@ -0,0 +1,26 @@ +--- +'stash': patch +--- + +`stash init --drizzle` now installs EQL v3 instead of v2. + +The Drizzle init flow pinned `--eql-version 2`, because `stash eql install +--drizzle` (the only migration-generating install path at the time) was +v2-only. That made `stash init --drizzle` the single flow that provisioned a v2 +database — a bare `stash eql install`, and init for every other integration, +already defaulted to v3. It also contradicted the `stash-drizzle` skill init +copies into the same project, which documents the v3 `@cipherstash/stack-drizzle/v3` +surface (`types.*` domains, `EncryptionV3`) and would have the user's agent +author v3 code against a v2 database. + +Init's Drizzle flow now routes through `stash eql migration --drizzle`, so it +stays migration-first (the install lands in your Drizzle migration history and +ships to every environment via `drizzle-kit migrate`) while emitting v3 SQL. +The generated migration also carries the `cs_migrations` tracking schema, so one +`drizzle-kit migrate` covers everything `stash encrypt …` needs. If `drizzle-kit` +isn't installed or configured, init now reports EQL as not installed and points +at `stash eql migration --drizzle` rather than aborting the run. + +The v2 Drizzle path remains available for existing deployments via an explicit +`stash eql install --drizzle --eql-version 2`; that command's error message now +points at the v3 alternative instead of only suggesting `--eql-version 2`. diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 74d857222..96b0f5f24 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -741,9 +741,16 @@ export function validateInstallFlags(options: InstallOptions): string | null { ? '--migrations-dir' : null if (v2OnlyFlag) { + // `--drizzle` has a v3 answer now (`stash eql migration --drizzle`, #691), + // so point there rather than at `--eql-version 2` — steering users to v2 + // is how new projects ended up on the legacy generation. + const v3Alternative = + v2OnlyFlag === '--drizzle' + ? ' For a v3 install as a Drizzle migration, use `stash eql migration --drizzle`.' + : '' 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).` + ? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.${v3Alternative}` + : `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).${v3Alternative}` } } diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 19223ec28..7f7c2969d 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -36,6 +36,14 @@ export interface EqlMigrationOptions { out?: string /** Describe what would happen without writing anything. */ dryRun?: boolean + /** + * Run as a step inside a larger flow (`stash init`) rather than as a + * standalone command. Suppresses the intro/outro banners and the trailing + * `printNextSteps()` note — init renders its own summary and agent handoff, + * so emitting ours as well would give the user two competing "what next" + * blocks. Purely presentational: the migration written is identical. + */ + embedded?: boolean } /** @@ -146,14 +154,15 @@ async function generateDrizzleEqlMigration( throw new CliExit(1) } - p.intro('CipherStash EQL migration') + const embedded = options.embedded ?? false + if (!embedded) p.intro('CipherStash EQL migration') if (options.dryRun) { p.note( `Would run: ${displayCmd}\nWould write the EQL v3 install SQL${options.supabase ? ' (with Supabase grants)' : ''} into the generated migration in ${outDir}`, 'Dry Run', ) - p.outro('Dry run complete.') + if (!embedded) p.outro('Dry run complete.') return } @@ -177,7 +186,7 @@ async function generateDrizzleEqlMigration( p.log.info( `Make sure drizzle-kit is installed and configured: ${execCommand(pm)} drizzle-kit --version`, ) - p.outro('Migration aborted.') + if (!embedded) p.outro('Migration aborted.') throw new CliExit(1) } s.stop('Custom Drizzle migration generated.') @@ -194,7 +203,7 @@ async function generateDrizzleEqlMigration( p.log.info( `If your drizzle.config.ts writes elsewhere, pass --out so it matches.`, ) - p.outro('Migration aborted.') + if (!embedded) p.outro('Migration aborted.') throw new CliExit(1) } @@ -207,7 +216,7 @@ async function generateDrizzleEqlMigration( s.stop('Failed to write migration file.') p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(migrationPath) - p.outro('Migration aborted.') + if (!embedded) p.outro('Migration aborted.') throw new CliExit(1) } @@ -216,6 +225,8 @@ async function generateDrizzleEqlMigration( `Run your Drizzle migrations to install EQL v3:\n\n ${execCommand(pm)} drizzle-kit migrate`, 'Next Steps', ) - printNextSteps() - p.outro('Done!') + if (!embedded) { + printNextSteps() + p.outro('Done!') + } } diff --git a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts index dfa3efbfd..4237b6205 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts @@ -4,6 +4,17 @@ import type { InitProvider, InitState } from '../../types.js' // installCommand is the unit under test's collaborator — mock it so we assert // what init asks for without touching a database. vi.mock('../../../db/install.js', () => ({ installCommand: vi.fn() })) +// The Drizzle branch generates a v3 migration instead of calling installCommand. +vi.mock('../../../eql/migration.js', () => ({ + eqlMigrationCommand: vi.fn(async () => undefined), +})) +// `eql install` normally scaffolds these; the Drizzle branch does it itself. +vi.mock('../../../db/config-scaffold.js', () => ({ + offerStashConfig: vi.fn(async () => 'src/encryption/index.ts'), +})) +vi.mock('../../../db/client-scaffold.js', () => ({ + ensureEncryptionClient: vi.fn(), +})) // `stash` must appear installed so the precondition guard doesn't short-circuit. vi.mock('../../utils.js', () => ({ isPackageInstalled: vi.fn(() => true) })) // Toggle interactivity per test (defaults to interactive in beforeEach). @@ -20,9 +31,18 @@ vi.mock('@clack/prompts', () => ({ import * as p from '@clack/prompts' import { isInteractive } from '../../../../config/tty.js' +import { ensureEncryptionClient } from '../../../db/client-scaffold.js' +import { offerStashConfig } from '../../../db/config-scaffold.js' import { installCommand } from '../../../db/install.js' +import { eqlMigrationCommand } from '../../../eql/migration.js' import { installEqlStep } from '../install-eql.js' +const drizzleState = { + integration: 'drizzle', + databaseUrl: 'postgresql://localhost:5432/app', +} as unknown as InitState +const drizzleProvider = { name: 'drizzle' } as unknown as InitProvider + const baseState = { integration: 'postgresql', databaseUrl: 'postgresql://localhost:5432/app', @@ -82,25 +102,93 @@ describe('installEqlStep', () => { expect(result.eqlMigrationPending).toBeFalsy() }) - it('maps a generated Drizzle migration to eqlMigrationPending, NOT eqlInstalled', async () => { - // The Drizzle path only WRITES a v2 migration — EQL isn't in the DB until - // the user runs `drizzle-kit migrate`. `installEqlStep` must carry that - // distinction through so `initCommand` doesn't claim "EQL installed". - // This is the seam the differential review flagged (PR #687): the step - // used to return `eqlInstalled: true` for every non-throwing outcome. - const drizzleState = { - integration: 'drizzle', - databaseUrl: 'postgresql://localhost:5432/app', - } as unknown as InitState - vi.mocked(installCommand).mockResolvedValueOnce('migration-generated') - - const result = await installEqlStep.run(drizzleState, { - name: 'drizzle', - } as unknown as InitProvider) - - // Pinned to v2 for the Drizzle migration path (v3 rejects --drizzle). - expect(vi.mocked(installCommand).mock.calls[0][0].eqlVersion).toBe('2') - expect(result.eqlInstalled).toBe(false) - expect(result.eqlMigrationPending).toBe(true) + it('never pins EQL v2 for the non-Drizzle paths', async () => { + await installEqlStep.run(baseState, provider) + + // `undefined` means "take the v3 default" in resolveEqlVersion. + expect( + vi.mocked(installCommand).mock.calls[0][0].eqlVersion, + ).toBeUndefined() + }) + + describe('Drizzle', () => { + it('generates an EQL v3 migration instead of running `eql install` (the v2 pin is gone)', async () => { + // Regression guard for the defect: init used to pass `eqlVersion: '2'` to + // installCommand, making `stash init --drizzle` the ONLY flow that + // provisioned a v2 database — while the stash-drizzle skill installed + // into the same project documents the v3 surface. The Drizzle flow must + // now go through `stash eql migration --drizzle`, which is v3. + await installEqlStep.run(drizzleState, drizzleProvider) + + expect(installCommand).not.toHaveBeenCalled() + expect(eqlMigrationCommand).toHaveBeenCalledTimes(1) + expect(vi.mocked(eqlMigrationCommand).mock.calls[0][0]).toMatchObject({ + drizzle: true, + embedded: true, + }) + }) + + it('maps a generated migration to eqlMigrationPending, NOT eqlInstalled', async () => { + // The migration is only WRITTEN — EQL isn't in the DB until the user runs + // `drizzle-kit migrate`. `installEqlStep` must carry that distinction + // through so `initCommand` doesn't claim "EQL installed" (PR #687). + const result = await installEqlStep.run(drizzleState, drizzleProvider) + + expect(result.eqlInstalled).toBe(false) + expect(result.eqlMigrationPending).toBe(true) + }) + + it('scaffolds stash.config.ts + the client, which `eql install` would have done (#581)', async () => { + // The Drizzle branch bypasses installCommand, so it owns the scaffolding + // that `scaffoldConfig: 'ensure'` used to provide. Without this, init + // would finish with no config and every downstream command would + // dead-end on 'Could not find stash.config.ts'. + await installEqlStep.run(drizzleState, drizzleProvider) + + expect(offerStashConfig).toHaveBeenCalledWith({ ensure: true }) + expect(ensureEncryptionClient).toHaveBeenCalledTimes(1) + }) + + it('forwards --supabase so a Drizzle-on-Supabase project gets the role grants', async () => { + await installEqlStep.run( + { ...drizzleState, integration: 'drizzle' } as InitState, + { name: 'supabase' } as unknown as InitProvider, + ) + + expect(vi.mocked(eqlMigrationCommand).mock.calls[0][0].supabase).toBe( + true, + ) + }) + + it('degrades to "not installed" (never crashes init) when drizzle-kit is missing', async () => { + // eqlMigrationCommand throws CliExit when drizzle-kit isn't installed or + // configured. init must absorb that and report honestly — a thrown + // CliExit here would abort the whole run after the user has already + // authenticated and had their client scaffolded. + vi.mocked(eqlMigrationCommand).mockRejectedValueOnce(new Error('boom')) + + const result = await installEqlStep.run(drizzleState, drizzleProvider) + + expect(result.eqlInstalled).toBe(false) + expect(result.eqlMigrationPending).toBeFalsy() + }) + + it('does not leak the database URL when the migration fails', async () => { + // Same reasoning as the `eql install` catch: errors on this path can + // carry a connection string with credentials. + vi.mocked(eqlMigrationCommand).mockRejectedValueOnce( + new Error('connect postgresql://user:hunter2@localhost:5432/app'), + ) + + await installEqlStep.run(drizzleState, drizzleProvider) + + const logged = [ + ...vi.mocked(p.log.error).mock.calls, + ...vi.mocked(p.note).mock.calls, + ] + .flat() + .join('\n') + expect(logged).not.toContain('hunter2') + }) }) }) diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 2cf0d28ed..1b4b8d3af 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -1,13 +1,20 @@ import * as p from '@clack/prompts' import { isInteractive } from '../../../config/tty.js' import { pinnedSpec } from '../../../runtime-versions.js' +import { ensureEncryptionClient } from '../../db/client-scaffold.js' +import { offerStashConfig } from '../../db/config-scaffold.js' import { installCommand } from '../../db/install.js' +import { eqlMigrationCommand } from '../../eql/migration.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' import { isPackageInstalled } from '../utils.js' /** - * Run `stash eql install` programmatically after a y/N confirm. + * Install EQL programmatically after a y/N confirm. + * + * Two routes, both EQL v3: Drizzle projects generate a v3 install migration + * (`stash eql migration --drizzle`) so the install lands in the project's + * migration history; everything else runs `stash eql install` directly. * * EQL is the Postgres extension every CipherStash query relies on. Without * it, the encryption client can't read or write to encrypted columns. @@ -90,17 +97,61 @@ export const installEqlStep: InitStep = { return { ...state, eqlInstalled: false } } + // Drizzle: generate an EQL **v3** migration (`stash eql migration + // --drizzle`) rather than routing through `eql install`. + // + // `eql install --drizzle` is v2-only — under the v3 default it rejects the + // flag outright, so init used to pin `eqlVersion: '2'` to get a migration + // at all. That pin made `stash init --drizzle` the one flow that provisions + // a v2 database while every other integration (and a bare `stash eql + // install`) gets v3, and it contradicted the stash-drizzle skill we install + // into the very same project — that skill documents the `/v3` surface + // (`types.*` domains, `EncryptionV3`) and would have the user's agent + // author v3 code against a v2 database. + // + // `stash eql migration --drizzle` (added in #691) closes that gap: v3 SQL, + // still migration-first, and it bundles the `cs_migrations` tracking schema + // so one `drizzle-kit migrate` covers everything `stash encrypt` needs. + // `eql install`'s config/client scaffolding isn't part of that command, so + // we do it here to keep the rest of the init contract identical. + if (drizzle) { + const clientPath = await offerStashConfig({ ensure: true }) + if (clientPath) { + ensureEncryptionClient(clientPath, process.cwd(), state.databaseUrl) + } + + try { + await eqlMigrationCommand({ + drizzle: true, + supabase: supabase || undefined, + embedded: true, + }) + } catch { + // Most likely drizzle-kit missing or misconfigured. Don't echo the + // error (same reasoning as below re: connection strings) — the command + // has already logged its own actionable diagnostics. + p.log.error( + 'Could not generate the EQL migration — check that drizzle-kit is installed and configured.', + ) + p.note( + 'Re-run with: stash eql migration --drizzle', + 'You can retry manually', + ) + return { ...state, eqlInstalled: false } + } + + // A migration file was WRITTEN, not applied — EQL lands in the database + // when the user runs `drizzle-kit migrate`. + return { ...state, eqlInstalled: false, eqlMigrationPending: true } + } + let outcome: Awaited> try { outcome = await installCommand({ 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, + // No `eqlVersion` — take the v3 default. (The Drizzle branch above + // returns before this point.) // 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', @@ -116,8 +167,8 @@ export const installEqlStep: InitStep = { return { ...state, eqlInstalled: false } } - // The Drizzle path (and Supabase `--migration` mode) only WRITES a - // migration file — EQL isn't in the database until the user applies it. + // Supabase `--migration` mode only WRITES a migration file — EQL isn't in + // the database until the user applies it. (Drizzle is handled above.) // Report that honestly rather than claiming the extension is installed; // the init summary turns this into "migration generated, apply it". if (outcome === 'migration-generated') { diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 333f9e4ff..be521ea5e 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -49,7 +49,7 @@ export interface InitState { * i.e. the extension is actually present in the target database. */ eqlInstalled?: boolean /** True when install-eql GENERATED a migration file but did not apply it - * (the Drizzle v2 path, or Supabase `--migration` mode). EQL is not in the + * (the Drizzle v3 path, or Supabase `--migration` mode). EQL is not in the * database yet — the user applies it with `drizzle-kit migrate` (or their * Supabase migration workflow). Distinct from `eqlInstalled` so the init * summary reports "migration generated, apply it" instead of a false diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 8fe14a531..31fea33c6 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -223,7 +223,7 @@ Seven mechanical steps, no agent handoff. It prompts only when it can't pick a s 3. **Resolve proxy choice** — CipherStash Proxy or direct SDK access (the default). Stored as `usesProxy` in `context.json`; this is what decides whether `db push` is part of your flow at all. Set by `--proxy` / `--no-proxy`; non-TTY without a flag defaults to SDK. 4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there. 5. **Install dependencies** — one combined prompt for `@cipherstash/stack` and `stash`. Skipped when both are present. -6. **Install EQL** — runs `eql install` against the resolved database. Skipped when EQL is already installed. +6. **Install EQL** — always EQL v3. **Drizzle** projects generate a v3 install migration (the same output as `eql migration --drizzle`, including the `cs_migrations` tracking schema) so the install lands in your migration history — apply it with `drizzle-kit migrate`; requires `drizzle-kit` to be installed and configured. **Prisma Next** is skipped (it installs EQL via `prisma-next migration apply`). Everything else runs `eql install` directly against the resolved database, and is skipped when EQL is already installed. 7. **Gather context** — detects available coding agents and writes `.cipherstash/context.json`. Flags: `--supabase`, `--drizzle`, `--prisma-next`, `--proxy` / `--no-proxy`, `--region `. diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 3c2a6ebbd..2083859d4 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -639,4 +639,4 @@ Encryption client operations (`encryptModel`, `bulkDecryptModels`, ...) don't th ## Legacy: EQL v2 -The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above. Note: `stash init --drizzle` currently pins EQL v2 because the v2 Drizzle-migration install path has no v3 equivalent yet. +The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above — `stash init --drizzle` generates an EQL **v3** migration via `stash eql migration --drizzle`. Reaching the v2 install path now requires opting in explicitly with `stash eql install --drizzle --eql-version 2`. From 30deef1c7aa475318411a80631cb3706b15b0463 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Jul 2026 11:06:27 +1000 Subject: [PATCH 2/2] test(cli): cover embedded suppression, v3Alternative pointer, and null-clientPath re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the three test gaps auxesis flagged on #705 — all callee-side or negative-case coverage the diff's new behaviour landed without: - eql/migration.ts: assert the six `if (!embedded)` suppression sites and the `?? false` default at the callee (banners emitted by default; suppressed + SQL still written when embedded; abort outro suppressed but CliExit still propagates). - db/install.ts: assert the `v3Alternative` pointer via its distinctive `stash eql migration --drizzle` substring (the pre-diff `/--drizzle/` match was unguarded), plus the lopsided-negative that it stays absent for --latest / --migration / --migrations-dir. - init/steps/install-eql.ts: cover the null-clientPath re-run path (config already on disk) and assert a plain Drizzle project passes supabase: undefined. Tests-only; no production change. --- .../src/__tests__/supabase-migration.test.ts | 30 ++++++++++ .../commands/eql/__tests__/migration.test.ts | 60 +++++++++++++++++++ .../init/steps/__tests__/install-eql.test.ts | 30 ++++++++++ 3 files changed, 120 insertions(+) diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts index 57b03a1c2..bf2a57f6c 100644 --- a/packages/cli/src/__tests__/supabase-migration.test.ts +++ b/packages/cli/src/__tests__/supabase-migration.test.ts @@ -338,6 +338,36 @@ describe('validateInstallFlags', () => { }), ).toBeNull() }) + + it('points --drizzle at the v3 migration command instead of only at --eql-version 2', () => { + // Steering users to `--eql-version 2` is how new projects ended up on the + // legacy generation (#691). Both the default-branch wording and the explicit + // `--eql-version 3` wording must carry the pointer. Assert the distinctive + // substring, not a bare `/--drizzle/` — that already appears in the base + // message body and would pass even if the pointer were dropped. + expect(validateInstallFlags({ drizzle: true })).toMatch( + /stash eql migration --drizzle/, + ) + expect(validateInstallFlags({ eqlVersion: '3', drizzle: true })).toMatch( + /stash eql migration --drizzle/, + ) + }) + + it('does NOT offer the Drizzle alternative for the other v2-only flags', () => { + // There is no `stash eql migration` route for these — suggesting one would + // send the user to a command that cannot help them. Guards the pointer being + // gated on `v2OnlyFlag === '--drizzle'`. + for (const opts of [ + { latest: true }, + { supabase: true, migration: true }, + { supabase: true, migrationsDir: 'db/migrations' }, + ]) { + expect(validateInstallFlags(opts)).not.toMatch(/stash eql migration/) + expect(validateInstallFlags({ ...opts, eqlVersion: '3' })).not.toMatch( + /stash eql migration/, + ) + } + }) }) describe('routeInstallPathForEqlVersion', () => { diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index 5ca7b2a7b..74357e3a3 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -11,6 +11,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CliExit } from '../../../cli/exit.js' import { messages } from '../../../messages.js' +import { printNextSteps } from '../../db/install.js' import { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js' // clack is chrome — silence it and spy on the error/note channels the command @@ -242,4 +243,63 @@ describe('eqlMigrationCommand — Drizzle', () => { expect(existsSync(scaffolded)).toBe(false) expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied') }) + + // The `embedded` flag is asserted at the caller (install-eql.test.ts) as + // *passed*; these pin what it *does* at the callee. All six suppression sites + // hang off `options.embedded ?? false`, and none were exercised — flipping the + // default or dropping an `if (!embedded)` guard would pass CI silently. + it('emits the standalone banners by default (embedded unset)', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + spawnMock.mockImplementation(() => { + fsWrite.real(join(out, '0000_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await eqlMigrationCommand({ drizzle: true, out }) + + expect(clack.intro).toHaveBeenCalled() + expect(clack.outro).toHaveBeenCalledWith('Done!') + expect(printNextSteps).toHaveBeenCalled() + }) + + it('suppresses intro/outro/next-steps when embedded, but still writes the SQL', async () => { + // `stash init` renders its own summary + agent handoff; a second "what next" + // block from here would compete with it. The migration itself is unchanged. + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + spawnMock.mockImplementation(() => { + fsWrite.real(join(out, '0000_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await eqlMigrationCommand({ drizzle: true, out, embedded: true }) + + expect(clack.intro).not.toHaveBeenCalled() + expect(clack.outro).not.toHaveBeenCalled() + expect(printNextSteps).not.toHaveBeenCalled() + // Presentational suppression only — the migration note itself still renders, + // and the SQL is written to the located file. + expect(clack.note).toHaveBeenCalledWith(expect.any(String), 'Next Steps') + expect(readFileSync(join(out, '0000_install-eql.sql'), 'utf-8')).toContain( + 'EQL v3 schema creation', + ) + }) + + it('suppresses the abort outro when embedded but still exits 1', async () => { + // `install-eql`'s catch depends on the CliExit propagating; embedded must + // silence the outro banner without swallowing the failure. + spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' }) + + await expect( + eqlMigrationCommand({ + drizzle: true, + out: join(tmp, 'drizzle'), + embedded: true, + }), + ).rejects.toBeInstanceOf(CliExit) + + expect(clack.outro).not.toHaveBeenCalled() + expect(clack.log.error).toHaveBeenCalledWith('boom') + }) }) diff --git a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts index 4237b6205..1f9e12939 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts @@ -160,6 +160,36 @@ describe('installEqlStep', () => { ) }) + it('passes supabase: undefined for a plain (non-Supabase) Drizzle project', async () => { + // Symmetric negative to the --supabase forward: a plain Drizzle project + // must NOT leak supabase: true, or the migration would append role grants + // no one asked for. `toMatchObject` above ignores the key, so it can't + // catch a leak — this asserts it explicitly. + await installEqlStep.run(drizzleState, drizzleProvider) + + expect( + vi.mocked(eqlMigrationCommand).mock.calls[0][0].supabase, + ).toBeUndefined() + }) + + it('still generates the migration when stash.config.ts already exists', async () => { + // offerStashConfig returns null when the config is already on disk — the + // re-run case (config-scaffold.ts: `if (existsSync(configPath)) return + // null`), i.e. every re-run of `stash init` on a Drizzle project. Nothing + // to scaffold, but the migration must still be written and the state must + // still say "pending". If the `if (clientPath)` guard were dropped, + // ensureEncryptionClient(null, …) would throw outside the try/catch and + // abort init after the user has already authenticated. + vi.mocked(offerStashConfig).mockResolvedValueOnce(null) + + const result = await installEqlStep.run(drizzleState, drizzleProvider) + + expect(ensureEncryptionClient).not.toHaveBeenCalled() + expect(eqlMigrationCommand).toHaveBeenCalledTimes(1) + expect(result.eqlMigrationPending).toBe(true) + expect(result.eqlInstalled).toBe(false) + }) + it('degrades to "not installed" (never crashes init) when drizzle-kit is missing', async () => { // eqlMigrationCommand throws CliExit when drizzle-kit isn't installed or // configured. init must absorb that and report honestly — a thrown