Skip to content

Commit c8726cd

Browse files
committed
fix(cli): stash init --drizzle installs EQL v3, not v2
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.
1 parent 4888995 commit c8726cd

8 files changed

Lines changed: 224 additions & 41 deletions

File tree

.changeset/init-drizzle-eql-v3.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
`stash init --drizzle` now installs EQL v3 instead of v2.
6+
7+
The Drizzle init flow pinned `--eql-version 2`, because `stash eql install
8+
--drizzle` (the only migration-generating install path at the time) was
9+
v2-only. That made `stash init --drizzle` the single flow that provisioned a v2
10+
database — a bare `stash eql install`, and init for every other integration,
11+
already defaulted to v3. It also contradicted the `stash-drizzle` skill init
12+
copies into the same project, which documents the v3 `@cipherstash/stack-drizzle/v3`
13+
surface (`types.*` domains, `EncryptionV3`) and would have the user's agent
14+
author v3 code against a v2 database.
15+
16+
Init's Drizzle flow now routes through `stash eql migration --drizzle`, so it
17+
stays migration-first (the install lands in your Drizzle migration history and
18+
ships to every environment via `drizzle-kit migrate`) while emitting v3 SQL.
19+
The generated migration also carries the `cs_migrations` tracking schema, so one
20+
`drizzle-kit migrate` covers everything `stash encrypt …` needs. If `drizzle-kit`
21+
isn't installed or configured, init now reports EQL as not installed and points
22+
at `stash eql migration --drizzle` rather than aborting the run.
23+
24+
The v2 Drizzle path remains available for existing deployments via an explicit
25+
`stash eql install --drizzle --eql-version 2`; that command's error message now
26+
points at the v3 alternative instead of only suggesting `--eql-version 2`.

packages/cli/src/commands/db/install.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -741,9 +741,16 @@ export function validateInstallFlags(options: InstallOptions): string | null {
741741
? '--migrations-dir'
742742
: null
743743
if (v2OnlyFlag) {
744+
// `--drizzle` has a v3 answer now (`stash eql migration --drizzle`, #691),
745+
// so point there rather than at `--eql-version 2` — steering users to v2
746+
// is how new projects ended up on the legacy generation.
747+
const v3Alternative =
748+
v2OnlyFlag === '--drizzle'
749+
? ' For a v3 install as a Drizzle migration, use `stash eql migration --drizzle`.'
750+
: ''
744751
return options.eqlVersion === '3'
745-
? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.`
746-
: `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).`
752+
? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.${v3Alternative}`
753+
: `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).${v3Alternative}`
747754
}
748755
}
749756

packages/cli/src/commands/eql/migration.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export interface EqlMigrationOptions {
3636
out?: string
3737
/** Describe what would happen without writing anything. */
3838
dryRun?: boolean
39+
/**
40+
* Run as a step inside a larger flow (`stash init`) rather than as a
41+
* standalone command. Suppresses the intro/outro banners and the trailing
42+
* `printNextSteps()` note — init renders its own summary and agent handoff,
43+
* so emitting ours as well would give the user two competing "what next"
44+
* blocks. Purely presentational: the migration written is identical.
45+
*/
46+
embedded?: boolean
3947
}
4048

4149
/**
@@ -146,14 +154,15 @@ async function generateDrizzleEqlMigration(
146154
throw new CliExit(1)
147155
}
148156

149-
p.intro('CipherStash EQL migration')
157+
const embedded = options.embedded ?? false
158+
if (!embedded) p.intro('CipherStash EQL migration')
150159

151160
if (options.dryRun) {
152161
p.note(
153162
`Would run: ${displayCmd}\nWould write the EQL v3 install SQL${options.supabase ? ' (with Supabase grants)' : ''} into the generated migration in ${outDir}`,
154163
'Dry Run',
155164
)
156-
p.outro('Dry run complete.')
165+
if (!embedded) p.outro('Dry run complete.')
157166
return
158167
}
159168

@@ -177,7 +186,7 @@ async function generateDrizzleEqlMigration(
177186
p.log.info(
178187
`Make sure drizzle-kit is installed and configured: ${execCommand(pm)} drizzle-kit --version`,
179188
)
180-
p.outro('Migration aborted.')
189+
if (!embedded) p.outro('Migration aborted.')
181190
throw new CliExit(1)
182191
}
183192
s.stop('Custom Drizzle migration generated.')
@@ -194,7 +203,7 @@ async function generateDrizzleEqlMigration(
194203
p.log.info(
195204
`If your drizzle.config.ts writes elsewhere, pass --out <dir> so it matches.`,
196205
)
197-
p.outro('Migration aborted.')
206+
if (!embedded) p.outro('Migration aborted.')
198207
throw new CliExit(1)
199208
}
200209

@@ -207,7 +216,7 @@ async function generateDrizzleEqlMigration(
207216
s.stop('Failed to write migration file.')
208217
p.log.error(error instanceof Error ? error.message : String(error))
209218
cleanupMigrationFile(migrationPath)
210-
p.outro('Migration aborted.')
219+
if (!embedded) p.outro('Migration aborted.')
211220
throw new CliExit(1)
212221
}
213222

@@ -216,6 +225,8 @@ async function generateDrizzleEqlMigration(
216225
`Run your Drizzle migrations to install EQL v3:\n\n ${execCommand(pm)} drizzle-kit migrate`,
217226
'Next Steps',
218227
)
219-
printNextSteps()
220-
p.outro('Done!')
228+
if (!embedded) {
229+
printNextSteps()
230+
p.outro('Done!')
231+
}
221232
}

packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import type { InitProvider, InitState } from '../../types.js'
44
// installCommand is the unit under test's collaborator — mock it so we assert
55
// what init asks for without touching a database.
66
vi.mock('../../../db/install.js', () => ({ installCommand: vi.fn() }))
7+
// The Drizzle branch generates a v3 migration instead of calling installCommand.
8+
vi.mock('../../../eql/migration.js', () => ({
9+
eqlMigrationCommand: vi.fn(async () => undefined),
10+
}))
11+
// `eql install` normally scaffolds these; the Drizzle branch does it itself.
12+
vi.mock('../../../db/config-scaffold.js', () => ({
13+
offerStashConfig: vi.fn(async () => 'src/encryption/index.ts'),
14+
}))
15+
vi.mock('../../../db/client-scaffold.js', () => ({
16+
ensureEncryptionClient: vi.fn(),
17+
}))
718
// `stash` must appear installed so the precondition guard doesn't short-circuit.
819
vi.mock('../../utils.js', () => ({ isPackageInstalled: vi.fn(() => true) }))
920
// Toggle interactivity per test (defaults to interactive in beforeEach).
@@ -20,9 +31,18 @@ vi.mock('@clack/prompts', () => ({
2031

2132
import * as p from '@clack/prompts'
2233
import { isInteractive } from '../../../../config/tty.js'
34+
import { ensureEncryptionClient } from '../../../db/client-scaffold.js'
35+
import { offerStashConfig } from '../../../db/config-scaffold.js'
2336
import { installCommand } from '../../../db/install.js'
37+
import { eqlMigrationCommand } from '../../../eql/migration.js'
2438
import { installEqlStep } from '../install-eql.js'
2539

40+
const drizzleState = {
41+
integration: 'drizzle',
42+
databaseUrl: 'postgresql://localhost:5432/app',
43+
} as unknown as InitState
44+
const drizzleProvider = { name: 'drizzle' } as unknown as InitProvider
45+
2646
const baseState = {
2747
integration: 'postgresql',
2848
databaseUrl: 'postgresql://localhost:5432/app',
@@ -82,25 +102,93 @@ describe('installEqlStep', () => {
82102
expect(result.eqlMigrationPending).toBeFalsy()
83103
})
84104

85-
it('maps a generated Drizzle migration to eqlMigrationPending, NOT eqlInstalled', async () => {
86-
// The Drizzle path only WRITES a v2 migration — EQL isn't in the DB until
87-
// the user runs `drizzle-kit migrate`. `installEqlStep` must carry that
88-
// distinction through so `initCommand` doesn't claim "EQL installed".
89-
// This is the seam the differential review flagged (PR #687): the step
90-
// used to return `eqlInstalled: true` for every non-throwing outcome.
91-
const drizzleState = {
92-
integration: 'drizzle',
93-
databaseUrl: 'postgresql://localhost:5432/app',
94-
} as unknown as InitState
95-
vi.mocked(installCommand).mockResolvedValueOnce('migration-generated')
96-
97-
const result = await installEqlStep.run(drizzleState, {
98-
name: 'drizzle',
99-
} as unknown as InitProvider)
100-
101-
// Pinned to v2 for the Drizzle migration path (v3 rejects --drizzle).
102-
expect(vi.mocked(installCommand).mock.calls[0][0].eqlVersion).toBe('2')
103-
expect(result.eqlInstalled).toBe(false)
104-
expect(result.eqlMigrationPending).toBe(true)
105+
it('never pins EQL v2 for the non-Drizzle paths', async () => {
106+
await installEqlStep.run(baseState, provider)
107+
108+
// `undefined` means "take the v3 default" in resolveEqlVersion.
109+
expect(
110+
vi.mocked(installCommand).mock.calls[0][0].eqlVersion,
111+
).toBeUndefined()
112+
})
113+
114+
describe('Drizzle', () => {
115+
it('generates an EQL v3 migration instead of running `eql install` (the v2 pin is gone)', async () => {
116+
// Regression guard for the defect: init used to pass `eqlVersion: '2'` to
117+
// installCommand, making `stash init --drizzle` the ONLY flow that
118+
// provisioned a v2 database — while the stash-drizzle skill installed
119+
// into the same project documents the v3 surface. The Drizzle flow must
120+
// now go through `stash eql migration --drizzle`, which is v3.
121+
await installEqlStep.run(drizzleState, drizzleProvider)
122+
123+
expect(installCommand).not.toHaveBeenCalled()
124+
expect(eqlMigrationCommand).toHaveBeenCalledTimes(1)
125+
expect(vi.mocked(eqlMigrationCommand).mock.calls[0][0]).toMatchObject({
126+
drizzle: true,
127+
embedded: true,
128+
})
129+
})
130+
131+
it('maps a generated migration to eqlMigrationPending, NOT eqlInstalled', async () => {
132+
// The migration is only WRITTEN — EQL isn't in the DB until the user runs
133+
// `drizzle-kit migrate`. `installEqlStep` must carry that distinction
134+
// through so `initCommand` doesn't claim "EQL installed" (PR #687).
135+
const result = await installEqlStep.run(drizzleState, drizzleProvider)
136+
137+
expect(result.eqlInstalled).toBe(false)
138+
expect(result.eqlMigrationPending).toBe(true)
139+
})
140+
141+
it('scaffolds stash.config.ts + the client, which `eql install` would have done (#581)', async () => {
142+
// The Drizzle branch bypasses installCommand, so it owns the scaffolding
143+
// that `scaffoldConfig: 'ensure'` used to provide. Without this, init
144+
// would finish with no config and every downstream command would
145+
// dead-end on 'Could not find stash.config.ts'.
146+
await installEqlStep.run(drizzleState, drizzleProvider)
147+
148+
expect(offerStashConfig).toHaveBeenCalledWith({ ensure: true })
149+
expect(ensureEncryptionClient).toHaveBeenCalledTimes(1)
150+
})
151+
152+
it('forwards --supabase so a Drizzle-on-Supabase project gets the role grants', async () => {
153+
await installEqlStep.run(
154+
{ ...drizzleState, integration: 'drizzle' } as InitState,
155+
{ name: 'supabase' } as unknown as InitProvider,
156+
)
157+
158+
expect(vi.mocked(eqlMigrationCommand).mock.calls[0][0].supabase).toBe(
159+
true,
160+
)
161+
})
162+
163+
it('degrades to "not installed" (never crashes init) when drizzle-kit is missing', async () => {
164+
// eqlMigrationCommand throws CliExit when drizzle-kit isn't installed or
165+
// configured. init must absorb that and report honestly — a thrown
166+
// CliExit here would abort the whole run after the user has already
167+
// authenticated and had their client scaffolded.
168+
vi.mocked(eqlMigrationCommand).mockRejectedValueOnce(new Error('boom'))
169+
170+
const result = await installEqlStep.run(drizzleState, drizzleProvider)
171+
172+
expect(result.eqlInstalled).toBe(false)
173+
expect(result.eqlMigrationPending).toBeFalsy()
174+
})
175+
176+
it('does not leak the database URL when the migration fails', async () => {
177+
// Same reasoning as the `eql install` catch: errors on this path can
178+
// carry a connection string with credentials.
179+
vi.mocked(eqlMigrationCommand).mockRejectedValueOnce(
180+
new Error('connect postgresql://user:hunter2@localhost:5432/app'),
181+
)
182+
183+
await installEqlStep.run(drizzleState, drizzleProvider)
184+
185+
const logged = [
186+
...vi.mocked(p.log.error).mock.calls,
187+
...vi.mocked(p.note).mock.calls,
188+
]
189+
.flat()
190+
.join('\n')
191+
expect(logged).not.toContain('hunter2')
192+
})
105193
})
106194
})

packages/cli/src/commands/init/steps/install-eql.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import * as p from '@clack/prompts'
22
import { isInteractive } from '../../../config/tty.js'
33
import { pinnedSpec } from '../../../runtime-versions.js'
4+
import { ensureEncryptionClient } from '../../db/client-scaffold.js'
5+
import { offerStashConfig } from '../../db/config-scaffold.js'
46
import { installCommand } from '../../db/install.js'
7+
import { eqlMigrationCommand } from '../../eql/migration.js'
58
import type { InitProvider, InitState, InitStep } from '../types.js'
69
import { CancelledError } from '../types.js'
710
import { isPackageInstalled } from '../utils.js'
811

912
/**
10-
* Run `stash eql install` programmatically after a y/N confirm.
13+
* Install EQL programmatically after a y/N confirm.
14+
*
15+
* Two routes, both EQL v3: Drizzle projects generate a v3 install migration
16+
* (`stash eql migration --drizzle`) so the install lands in the project's
17+
* migration history; everything else runs `stash eql install` directly.
1118
*
1219
* EQL is the Postgres extension every CipherStash query relies on. Without
1320
* it, the encryption client can't read or write to encrypted columns.
@@ -90,17 +97,61 @@ export const installEqlStep: InitStep = {
9097
return { ...state, eqlInstalled: false }
9198
}
9299

100+
// Drizzle: generate an EQL **v3** migration (`stash eql migration
101+
// --drizzle`) rather than routing through `eql install`.
102+
//
103+
// `eql install --drizzle` is v2-only — under the v3 default it rejects the
104+
// flag outright, so init used to pin `eqlVersion: '2'` to get a migration
105+
// at all. That pin made `stash init --drizzle` the one flow that provisions
106+
// a v2 database while every other integration (and a bare `stash eql
107+
// install`) gets v3, and it contradicted the stash-drizzle skill we install
108+
// into the very same project — that skill documents the `/v3` surface
109+
// (`types.*` domains, `EncryptionV3`) and would have the user's agent
110+
// author v3 code against a v2 database.
111+
//
112+
// `stash eql migration --drizzle` (added in #691) closes that gap: v3 SQL,
113+
// still migration-first, and it bundles the `cs_migrations` tracking schema
114+
// so one `drizzle-kit migrate` covers everything `stash encrypt` needs.
115+
// `eql install`'s config/client scaffolding isn't part of that command, so
116+
// we do it here to keep the rest of the init contract identical.
117+
if (drizzle) {
118+
const clientPath = await offerStashConfig({ ensure: true })
119+
if (clientPath) {
120+
ensureEncryptionClient(clientPath, process.cwd(), state.databaseUrl)
121+
}
122+
123+
try {
124+
await eqlMigrationCommand({
125+
drizzle: true,
126+
supabase: supabase || undefined,
127+
embedded: true,
128+
})
129+
} catch {
130+
// Most likely drizzle-kit missing or misconfigured. Don't echo the
131+
// error (same reasoning as below re: connection strings) — the command
132+
// has already logged its own actionable diagnostics.
133+
p.log.error(
134+
'Could not generate the EQL migration — check that drizzle-kit is installed and configured.',
135+
)
136+
p.note(
137+
'Re-run with: stash eql migration --drizzle',
138+
'You can retry manually',
139+
)
140+
return { ...state, eqlInstalled: false }
141+
}
142+
143+
// A migration file was WRITTEN, not applied — EQL lands in the database
144+
// when the user runs `drizzle-kit migrate`.
145+
return { ...state, eqlInstalled: false, eqlMigrationPending: true }
146+
}
147+
93148
let outcome: Awaited<ReturnType<typeof installCommand>>
94149
try {
95150
outcome = await installCommand({
96151
supabase: supabase || undefined,
97-
drizzle: drizzle || undefined,
98152
databaseUrl: state.databaseUrl,
99-
// EQL v3 is the default, but the Drizzle migration path is v2-only, so
100-
// pin v2 when we're driving the Drizzle flow — otherwise the install
101-
// would reject `--drizzle` under the v3 default. Supabase and plain
102-
// Postgres projects take the v3 default (direct install).
103-
eqlVersion: drizzle ? '2' : undefined,
153+
// No `eqlVersion` — take the v3 default. (The Drizzle branch above
154+
// returns before this point.)
104155
// init passes a resolved URL to avoid re-prompting, but still wants a
105156
// config scaffolded — this is NOT a one-shot `--database-url` run.
106157
scaffoldConfig: 'ensure',
@@ -116,8 +167,8 @@ export const installEqlStep: InitStep = {
116167
return { ...state, eqlInstalled: false }
117168
}
118169

119-
// The Drizzle path (and Supabase `--migration` mode) only WRITES a
120-
// migration file — EQL isn't in the database until the user applies it.
170+
// Supabase `--migration` mode only WRITES a migration file — EQL isn't in
171+
// the database until the user applies it. (Drizzle is handled above.)
121172
// Report that honestly rather than claiming the extension is installed;
122173
// the init summary turns this into "migration generated, apply it".
123174
if (outcome === 'migration-generated') {

packages/cli/src/commands/init/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export interface InitState {
4949
* i.e. the extension is actually present in the target database. */
5050
eqlInstalled?: boolean
5151
/** True when install-eql GENERATED a migration file but did not apply it
52-
* (the Drizzle v2 path, or Supabase `--migration` mode). EQL is not in the
52+
* (the Drizzle v3 path, or Supabase `--migration` mode). EQL is not in the
5353
* database yet — the user applies it with `drizzle-kit migrate` (or their
5454
* Supabase migration workflow). Distinct from `eqlInstalled` so the init
5555
* summary reports "migration generated, apply it" instead of a false

skills/stash-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ Seven mechanical steps, no agent handoff. It prompts only when it can't pick a s
223223
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.
224224
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.
225225
5. **Install dependencies** — one combined prompt for `@cipherstash/stack` and `stash`. Skipped when both are present.
226-
6. **Install EQL** — runs `eql install` against the resolved database. Skipped when EQL is already installed.
226+
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.
227227
7. **Gather context** — detects available coding agents and writes `.cipherstash/context.json`.
228228

229229
Flags: `--supabase`, `--drizzle`, `--prisma-next`, `--proxy` / `--no-proxy`, `--region <slug>`.

0 commit comments

Comments
 (0)