Skip to content

Commit fbb3780

Browse files
authored
Merge pull request #705 from cipherstash/fix/init-drizzle-eql-v3
fix(cli): stash init --drizzle installs EQL v3, not v2
2 parents 4888995 + 30deef1 commit fbb3780

10 files changed

Lines changed: 344 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/__tests__/supabase-migration.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,36 @@ describe('validateInstallFlags', () => {
338338
}),
339339
).toBeNull()
340340
})
341+
342+
it('points --drizzle at the v3 migration command instead of only at --eql-version 2', () => {
343+
// Steering users to `--eql-version 2` is how new projects ended up on the
344+
// legacy generation (#691). Both the default-branch wording and the explicit
345+
// `--eql-version 3` wording must carry the pointer. Assert the distinctive
346+
// substring, not a bare `/--drizzle/` — that already appears in the base
347+
// message body and would pass even if the pointer were dropped.
348+
expect(validateInstallFlags({ drizzle: true })).toMatch(
349+
/stash eql migration --drizzle/,
350+
)
351+
expect(validateInstallFlags({ eqlVersion: '3', drizzle: true })).toMatch(
352+
/stash eql migration --drizzle/,
353+
)
354+
})
355+
356+
it('does NOT offer the Drizzle alternative for the other v2-only flags', () => {
357+
// There is no `stash eql migration` route for these — suggesting one would
358+
// send the user to a command that cannot help them. Guards the pointer being
359+
// gated on `v2OnlyFlag === '--drizzle'`.
360+
for (const opts of [
361+
{ latest: true },
362+
{ supabase: true, migration: true },
363+
{ supabase: true, migrationsDir: 'db/migrations' },
364+
]) {
365+
expect(validateInstallFlags(opts)).not.toMatch(/stash eql migration/)
366+
expect(validateInstallFlags({ ...opts, eqlVersion: '3' })).not.toMatch(
367+
/stash eql migration/,
368+
)
369+
}
370+
})
341371
})
342372

343373
describe('routeInstallPathForEqlVersion', () => {

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/__tests__/migration.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { join } from 'node:path'
1111
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1212
import { CliExit } from '../../../cli/exit.js'
1313
import { messages } from '../../../messages.js'
14+
import { printNextSteps } from '../../db/install.js'
1415
import { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js'
1516

1617
// clack is chrome — silence it and spy on the error/note channels the command
@@ -242,4 +243,63 @@ describe('eqlMigrationCommand — Drizzle', () => {
242243
expect(existsSync(scaffolded)).toBe(false)
243244
expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied')
244245
})
246+
247+
// The `embedded` flag is asserted at the caller (install-eql.test.ts) as
248+
// *passed*; these pin what it *does* at the callee. All six suppression sites
249+
// hang off `options.embedded ?? false`, and none were exercised — flipping the
250+
// default or dropping an `if (!embedded)` guard would pass CI silently.
251+
it('emits the standalone banners by default (embedded unset)', async () => {
252+
const out = join(tmp, 'drizzle')
253+
mkdirSync(out, { recursive: true })
254+
spawnMock.mockImplementation(() => {
255+
fsWrite.real(join(out, '0000_install-eql.sql'), '')
256+
return { status: 0, stdout: '', stderr: '' }
257+
})
258+
259+
await eqlMigrationCommand({ drizzle: true, out })
260+
261+
expect(clack.intro).toHaveBeenCalled()
262+
expect(clack.outro).toHaveBeenCalledWith('Done!')
263+
expect(printNextSteps).toHaveBeenCalled()
264+
})
265+
266+
it('suppresses intro/outro/next-steps when embedded, but still writes the SQL', async () => {
267+
// `stash init` renders its own summary + agent handoff; a second "what next"
268+
// block from here would compete with it. The migration itself is unchanged.
269+
const out = join(tmp, 'drizzle')
270+
mkdirSync(out, { recursive: true })
271+
spawnMock.mockImplementation(() => {
272+
fsWrite.real(join(out, '0000_install-eql.sql'), '')
273+
return { status: 0, stdout: '', stderr: '' }
274+
})
275+
276+
await eqlMigrationCommand({ drizzle: true, out, embedded: true })
277+
278+
expect(clack.intro).not.toHaveBeenCalled()
279+
expect(clack.outro).not.toHaveBeenCalled()
280+
expect(printNextSteps).not.toHaveBeenCalled()
281+
// Presentational suppression only — the migration note itself still renders,
282+
// and the SQL is written to the located file.
283+
expect(clack.note).toHaveBeenCalledWith(expect.any(String), 'Next Steps')
284+
expect(readFileSync(join(out, '0000_install-eql.sql'), 'utf-8')).toContain(
285+
'EQL v3 schema creation',
286+
)
287+
})
288+
289+
it('suppresses the abort outro when embedded but still exits 1', async () => {
290+
// `install-eql`'s catch depends on the CliExit propagating; embedded must
291+
// silence the outro banner without swallowing the failure.
292+
spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' })
293+
294+
await expect(
295+
eqlMigrationCommand({
296+
drizzle: true,
297+
out: join(tmp, 'drizzle'),
298+
embedded: true,
299+
}),
300+
).rejects.toBeInstanceOf(CliExit)
301+
302+
expect(clack.outro).not.toHaveBeenCalled()
303+
expect(clack.log.error).toHaveBeenCalledWith('boom')
304+
})
245305
})

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
}

0 commit comments

Comments
 (0)