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/__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/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/__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/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..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
@@ -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,123 @@ 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('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
+ // 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`.