Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/init-drizzle-eql-v3.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 30 additions & 0 deletions packages/cli/src/__tests__/supabase-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Comment thread
tobyhede marked this conversation as resolved.
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}`
}
}

Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/commands/eql/__tests__/migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
})
})
25 changes: 18 additions & 7 deletions packages/cli/src/commands/eql/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -146,14 +154,15 @@ async function generateDrizzleEqlMigration(
throw new CliExit(1)
}

p.intro('CipherStash EQL migration')
const embedded = options.embedded ?? false
Comment thread
tobyhede marked this conversation as resolved.
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
}

Expand All @@ -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.')
Expand All @@ -194,7 +203,7 @@ async function generateDrizzleEqlMigration(
p.log.info(
`If your drizzle.config.ts writes elsewhere, pass --out <dir> so it matches.`,
)
p.outro('Migration aborted.')
if (!embedded) p.outro('Migration aborted.')
throw new CliExit(1)
}

Expand All @@ -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)
}

Expand All @@ -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!')
}
}
Loading
Loading