Skip to content

Commit fd0af48

Browse files
committed
fix(cli): harden stash eql migration per review (#691)
Addresses the review on #691 — correctness, security, telemetry, and coverage: - Run the PROJECT-LOCAL drizzle-kit (`pnpm exec` / `npx --no-install`), not the `pnpm dlx`/`yarn dlx` download-and-run form, so it resolves the project's own drizzle.config.ts + schema. New shared `execCommand`/`execArgv` in init/utils (setup-prompt's private copy folded in). - Invoke via `spawnSync` with an argv array instead of `execSync` on a shell string — a `--name` with spaces or shell metacharacters is now one inert token (no word-splitting, no command injection). Plus a `[\w-]+` name guard. - Pass `--out` through to `drizzle-kit generate` (always) so the flag actually steers where the migration is written, and our lookup can't miss it. - Validation exits and every abort throw `CliExit(1)` instead of `process.exit`, so the telemetry `finally` runs and the branches are unit-testable. - Guard `loadBundledEqlSql` (corrupt bundle → clean error, not a fatal trace), add the missing `p.intro`, and call `printNextSteps()` so the now-preferred install path isn't less helpful than `eql install`. - HELP banner lists `eql migration`. Tests: 14 migration unit (target selection incl. `--supabase`-alone, name guard, dry-run, argv threading, non-zero drizzle-kit, write-failure cleanup, SQL order), 3 `findGeneratedMigration` unit (now public API), e2e smoke + `eql migration --help`. 557 unit / 65 e2e green, code:check clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 6c34978 commit fd0af48

11 files changed

Lines changed: 401 additions & 68 deletions

File tree

packages/cli/src/bin/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Commands:
108108
telemetry <sub> Manage anonymous usage analytics (status, enable, disable)
109109
110110
eql install Scaffold stash.config.ts (if missing) and install EQL extensions
111+
eql migration Generate an EQL v3 install migration for your ORM (Drizzle)
111112
eql upgrade Upgrade EQL extensions to the latest version
112113
eql status Show EQL installation status
113114

packages/cli/src/cli/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,13 @@ export const registry: CommandGroup[] = [
405405
name: '--name',
406406
value: '<name>',
407407
description:
408-
'Name for the generated migration (Drizzle). Defaults to `install-eql`.',
408+
'Name for the generated migration (Drizzle). Letters, numbers, dashes, underscores only. Defaults to `install-eql`.',
409409
},
410410
{
411411
name: '--out',
412412
value: '<path>',
413413
description:
414-
'Directory to write the generated migration into (Drizzle). Defaults to `drizzle`.',
414+
'Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts.',
415415
},
416416
DRY_RUN_FLAG,
417417
],
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { findGeneratedMigration } from '../install.js'
6+
7+
/**
8+
* `findGeneratedMigration` was promoted to public API by the `eql migration`
9+
* command and now has two consumers (`db install --drizzle` and
10+
* `eql migration --drizzle`), so its branches are pinned directly — a change for
11+
* one consumer must not silently break the other.
12+
*/
13+
describe('findGeneratedMigration', () => {
14+
let dir: string
15+
beforeEach(() => {
16+
dir = mkdtempSync(join(tmpdir(), 'stash-find-migration-'))
17+
})
18+
afterEach(() => {
19+
rmSync(dir, { recursive: true, force: true })
20+
})
21+
22+
it('throws when the out directory does not exist', async () => {
23+
await expect(
24+
findGeneratedMigration(join(dir, 'nope'), 'install-eql'),
25+
).rejects.toThrow(/output directory not found/)
26+
})
27+
28+
it('throws when no .sql file matches the migration name', async () => {
29+
writeFileSync(join(dir, '0000_other.sql'), '')
30+
await expect(findGeneratedMigration(dir, 'install-eql')).rejects.toThrow(
31+
/Could not find a migration matching "install-eql"/,
32+
)
33+
})
34+
35+
it('returns the highest-numbered match, ignoring non-.sql and non-matching entries', async () => {
36+
for (const f of [
37+
'0000_install-eql.sql',
38+
'0010_install-eql.sql',
39+
'0011_install-eql.txt', // not .sql
40+
'0001_users.sql', // doesn't match the name
41+
]) {
42+
writeFileSync(join(dir, f), '')
43+
}
44+
// Relies on drizzle-kit's zero-padded 4-digit prefix for lexical == numeric.
45+
expect(await findGeneratedMigration(dir, 'install-eql')).toBe(
46+
join(dir, '0010_install-eql.sql'),
47+
)
48+
})
49+
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ function resolveProviderOptions(
400400
return { supabase, drizzle, excludeOperatorFamily }
401401
}
402402

403-
function printNextSteps(): void {
403+
export function printNextSteps(): void {
404404
p.note(
405405
[
406406
'Your project is set up. To encrypt your first column, pick the path',
Lines changed: 208 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,79 @@
1-
import { describe, expect, it } from 'vitest'
2-
import { buildEqlV3MigrationSql } from '../migration.js'
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from 'node:fs'
9+
import { tmpdir } from 'node:os'
10+
import { join } from 'node:path'
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import { CliExit } from '../../../cli/exit.js'
13+
import { messages } from '../../../messages.js'
14+
import { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js'
15+
16+
// clack is chrome — silence it and spy on the error/note channels the command
17+
// reports through.
18+
const clack = vi.hoisted(() => ({
19+
spinnerInstance: { start: vi.fn(), stop: vi.fn() },
20+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() },
21+
intro: vi.fn(),
22+
note: vi.fn(),
23+
outro: vi.fn(),
24+
}))
25+
vi.mock('@clack/prompts', () => ({
26+
spinner: vi.fn(() => clack.spinnerInstance),
27+
log: clack.log,
28+
intro: clack.intro,
29+
note: clack.note,
30+
outro: clack.outro,
31+
}))
32+
33+
// Stub the drizzle-kit scaffold — only the child process is faked.
34+
const spawnMock = vi.hoisted(() => vi.fn())
35+
vi.mock('node:child_process', () => ({ spawnSync: spawnMock }))
36+
37+
// `node:fs` stays REAL by default (so `loadBundledEqlSql` reads the bundled SQL
38+
// and the tmpdir writes/reads work) — only `writeFileSync` is a spy that
39+
// delegates to the real impl, so the cleanup test can make just the SQL write
40+
// throw without touching everything else. `beforeEach` restores the delegating
41+
// default after `clearAllMocks`.
42+
const fsWrite = vi.hoisted(() => ({
43+
real: undefined as unknown as typeof import('node:fs').writeFileSync,
44+
spy: vi.fn(),
45+
}))
46+
vi.mock('node:fs', async (importOriginal) => {
47+
const actual = await importOriginal<typeof import('node:fs')>()
48+
fsWrite.real = actual.writeFileSync
49+
return { ...actual, default: actual, writeFileSync: fsWrite.spy }
50+
})
51+
52+
// `printNextSteps` lives in the install module, which drags in `pg`. Stub it;
53+
// the two helpers we reuse (`findGeneratedMigration`, `cleanupMigrationFile`)
54+
// stay real and act on the tmpdir.
55+
vi.mock('../../db/install.js', async (importOriginal) => {
56+
const actual = await importOriginal<typeof import('../../db/install.js')>()
57+
return { ...actual, printNextSteps: vi.fn() }
58+
})
59+
60+
beforeEach(() => {
61+
fsWrite.spy.mockImplementation(fsWrite.real)
62+
})
63+
afterEach(() => {
64+
vi.clearAllMocks()
65+
})
366

467
/**
5-
* `buildEqlV3MigrationSql` is the pure core of `stash eql migration --drizzle`:
6-
* it assembles the migration contents from the CLI's bundled v3 install SQL,
7-
* the optional Supabase grants, and the `cs_migrations` tracking schema. The
8-
* file-writing orchestration around it (drizzle-kit scaffold + inject) is thin
9-
* and I/O-bound, so the assembly is where the contract lives.
68+
* `buildEqlV3MigrationSql` is the pure core: it assembles the migration from the
69+
* CLI's bundled v3 install SQL, the optional Supabase grants, and the
70+
* `cs_migrations` tracking schema.
1071
*/
1172
describe('buildEqlV3MigrationSql', () => {
1273
it('emits the EQL v3 install bundle and the cs_migrations tracking schema', () => {
1374
const sql = buildEqlV3MigrationSql({ supabase: false })
14-
// v3 bundle, not v2 — the whole point of the command.
1575
expect(sql).toContain('EQL v3 schema creation')
1676
expect(sql).toContain('eql_v3')
17-
// Bundled tracking schema so one migration run is enough for `stash encrypt`.
1877
expect(sql).toContain('cs_migrations')
1978
})
2079

@@ -35,12 +94,145 @@ describe('buildEqlV3MigrationSql', () => {
3594
)
3695
})
3796

38-
it('is a superset of the non-supabase content when --supabase is set', () => {
39-
const base = buildEqlV3MigrationSql({ supabase: false })
40-
const supa = buildEqlV3MigrationSql({ supabase: true })
41-
// The grants are additive: everything in the base still appears.
42-
expect(supa.length).toBeGreaterThan(base.length)
43-
expect(supa).toContain('EQL v3 schema creation')
44-
expect(supa).toContain('cs_migrations')
97+
it('orders the bundle: schema creation → grants → tracking schema', () => {
98+
// Order is the contract: `GRANT ... ON SCHEMA eql_v3` against a not-yet-
99+
// created schema is a hard error at migrate time, so `toContain` isn't
100+
// enough — the offsets must be monotonic.
101+
const sql = buildEqlV3MigrationSql({ supabase: true })
102+
const schemaAt = sql.indexOf('EQL v3 schema creation')
103+
const grantAt = sql.indexOf('GRANT USAGE ON SCHEMA eql_v3 TO')
104+
const trackingAt = sql.indexOf(
105+
'-- CipherStash encryption-migration tracking schema.',
106+
)
107+
expect(schemaAt).toBeGreaterThanOrEqual(0)
108+
expect(grantAt).toBeGreaterThan(schemaAt)
109+
expect(trackingAt).toBeGreaterThan(grantAt)
110+
})
111+
})
112+
113+
describe('eqlMigrationCommand — target selection', () => {
114+
it.each([
115+
['no target', {}, () => messages.eql.migrationNeedsTarget],
116+
[
117+
'both targets',
118+
{ drizzle: true, prisma: true },
119+
() => messages.eql.migrationOneTarget,
120+
],
121+
[
122+
'--prisma',
123+
{ prisma: true },
124+
() => messages.eql.migrationPrismaUnavailable,
125+
],
126+
// `--supabase` is a modifier, not a target.
127+
[
128+
'--supabase alone',
129+
{ supabase: true },
130+
() => messages.eql.migrationNeedsTarget,
131+
],
132+
])('exits 1 with an actionable message for %s', async (_label, opts, msg) => {
133+
await expect(eqlMigrationCommand(opts)).rejects.toBeInstanceOf(CliExit)
134+
expect(clack.log.error).toHaveBeenCalledWith(msg())
135+
expect(spawnMock).not.toHaveBeenCalled()
136+
})
137+
})
138+
139+
describe('eqlMigrationCommand — Drizzle', () => {
140+
let tmp: string
141+
beforeEach(() => {
142+
tmp = mkdtempSync(join(tmpdir(), 'stash-eql-migration-'))
143+
})
144+
afterEach(() => {
145+
rmSync(tmp, { recursive: true, force: true })
146+
})
147+
148+
it('rejects a migration name with unsafe characters before spawning', async () => {
149+
await expect(
150+
eqlMigrationCommand({ drizzle: true, name: 'a b; rm -rf /' }),
151+
).rejects.toBeInstanceOf(CliExit)
152+
expect(clack.log.error).toHaveBeenCalledWith(messages.eql.migrationBadName)
153+
expect(spawnMock).not.toHaveBeenCalled()
154+
})
155+
156+
it('dry run neither spawns drizzle-kit nor creates the out directory', async () => {
157+
const out = join(tmp, 'drizzle')
158+
await eqlMigrationCommand({ drizzle: true, dryRun: true, out })
159+
expect(spawnMock).not.toHaveBeenCalled()
160+
expect(existsSync(out)).toBe(false)
161+
expect(clack.note).toHaveBeenCalledWith(
162+
expect.stringContaining(
163+
'drizzle-kit generate --custom --name=install-eql',
164+
),
165+
'Dry Run',
166+
)
167+
})
168+
169+
it('dry run says the grants would be included under --supabase', async () => {
170+
await eqlMigrationCommand({
171+
drizzle: true,
172+
dryRun: true,
173+
supabase: true,
174+
out: join(tmp, 'd'),
175+
})
176+
expect(clack.note).toHaveBeenCalledWith(
177+
expect.stringContaining('(with Supabase grants)'),
178+
'Dry Run',
179+
)
180+
})
181+
182+
it('threads --name/--out into drizzle-kit (as argv, no shell) and writes the SQL', async () => {
183+
const out = join(tmp, 'db', 'migrations')
184+
mkdirSync(out, { recursive: true })
185+
// Stand in for drizzle-kit scaffolding an empty custom migration.
186+
spawnMock.mockImplementation(() => {
187+
writeFileSync(join(out, '0000_add-eql.sql'), '')
188+
return { status: 0, stdout: '', stderr: '' }
189+
})
190+
191+
await eqlMigrationCommand({ drizzle: true, name: 'add-eql', out })
192+
193+
// argv array, never a shell string — the name/out are discrete tokens.
194+
const [, argv] = spawnMock.mock.calls[0]
195+
expect(argv).toContain('drizzle-kit')
196+
expect(argv).toContain('--name=add-eql')
197+
expect(argv).toContain(`--out=${out}`)
198+
199+
const written = readFileSync(join(out, '0000_add-eql.sql'), 'utf-8')
200+
expect(written).toContain('EQL v3 schema creation')
201+
expect(written).toContain('cs_migrations')
202+
})
203+
204+
it('aborts (exit 1) when drizzle-kit exits non-zero', async () => {
205+
spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' })
206+
await expect(
207+
eqlMigrationCommand({ drizzle: true, out: join(tmp, 'drizzle') }),
208+
).rejects.toBeInstanceOf(CliExit)
209+
expect(clack.log.error).toHaveBeenCalledWith('boom')
210+
})
211+
212+
it('removes the scaffolded migration when writing the SQL fails', async () => {
213+
const out = join(tmp, 'drizzle')
214+
mkdirSync(out, { recursive: true })
215+
const scaffolded = join(out, '0000_install-eql.sql')
216+
spawnMock.mockImplementation(() => {
217+
// drizzle-kit's empty custom migration (delegates to real write).
218+
fsWrite.real(scaffolded, '')
219+
return { status: 0, stdout: '', stderr: '' }
220+
})
221+
// Throw on the SQL write specifically (the big bundle), letting the empty
222+
// scaffold write through — robust to call order.
223+
fsWrite.spy.mockImplementation(((path: string, data: unknown, ...rest) => {
224+
if (typeof data === 'string' && data.includes('EQL v3 schema creation')) {
225+
throw new Error('EACCES: permission denied')
226+
}
227+
return fsWrite.real(path, data as string, ...(rest as []))
228+
}) as typeof import('node:fs').writeFileSync)
229+
230+
await expect(
231+
eqlMigrationCommand({ drizzle: true, out }),
232+
).rejects.toBeInstanceOf(CliExit)
233+
234+
// The empty scaffold must not survive — drizzle-kit would happily run it.
235+
expect(existsSync(scaffolded)).toBe(false)
236+
expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied')
45237
})
46238
})

0 commit comments

Comments
 (0)