From 46dde37b9ba726217d19deef330170c2dc603b79 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 20 Jul 2026 11:46:40 +1000 Subject: [PATCH 1/4] fix(cli): validate --name and pass --out in the v2 Drizzle migration generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stash eql install --drizzle` (EQL v2) built a shell command string and ran it via execSync, interpolating an unvalidated `--name`. A name carrying shell metacharacters was executed: stash eql install --drizzle --eql-version 2 --name 'x; rm -rf ~' It also computed `outDir` from `--out`, searched it for the generated file, but never passed `--out` to drizzle-kit — so any project whose drizzle.config.ts writes elsewhere had the migration written in one place and looked for in another, dying at the locate step. Both fixes mirror the v3 generator (`stash eql migration --drizzle`), which already had them: reuse its `SAFE_MIGRATION_NAME` guard and invoke drizzle-kit through spawnSync with an argv array (no shell). `SAFE_MIGRATION_NAME` moves to install.ts — migration.ts already imports from it, so the shared constant sits on that side of the existing edge and no import cycle is introduced. The generator's five `process.exit(1)` sites become `throw new CliExit(1)` so the branches are unit testable and the telemetry `finally` in main.ts still runs. `install-eql.ts` re-throws CliExit from its broad catch, so a hard stop during `stash init` stays a hard stop instead of being reframed as a database connection failure. --- .changeset/khaki-pugs-play.md | 10 ++ .../generate-drizzle-migration.test.ts | 131 ++++++++++++++++++ packages/cli/src/commands/db/install.ts | 94 +++++++++---- packages/cli/src/commands/eql/migration.ts | 4 +- .../src/commands/init/steps/install-eql.ts | 8 +- skills/stash-cli/SKILL.md | 6 +- 6 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 .changeset/khaki-pugs-play.md create mode 100644 packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts diff --git a/.changeset/khaki-pugs-play.md b/.changeset/khaki-pugs-play.md new file mode 100644 index 00000000..2490b045 --- /dev/null +++ b/.changeset/khaki-pugs-play.md @@ -0,0 +1,10 @@ +--- +'stash': patch +--- + +Fix two defects in the Drizzle migration generator used by `stash eql install --drizzle` (EQL v2): + +- **`--name` is now validated and no longer reaches a shell.** The migration name was interpolated into a shell command string, so a name containing shell metacharacters (e.g. `--name 'x; rm -rf ~'`) was executed. `--name` is now restricted to letters, numbers, dashes, and underscores, and drizzle-kit is invoked with an argv array instead of a shell string. +- **`--out` is now actually passed to drizzle-kit.** The flag was used to search for the generated migration but never handed to `drizzle-kit generate`, so any project whose `drizzle.config.ts` writes migrations outside `drizzle/` had the file written in one place and searched for in another, failing with "migration file not found". + +`stash eql migration --drizzle` (EQL v3) already had both fixes and is unchanged. diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts new file mode 100644 index 00000000..15ec0f2b --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts @@ -0,0 +1,131 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as p from '@clack/prompts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CliExit } from '../../../cli/exit.js' +import { messages } from '../../../messages.js' + +// clack is chrome — silence it and spy on the channels the generator reports +// through. The spinner instance doubles as the `s` argument. +const clack = vi.hoisted(() => ({ + spinnerInstance: { start: vi.fn(), stop: vi.fn() }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + intro: vi.fn(), + note: vi.fn(), + outro: vi.fn(), +})) +vi.mock('@clack/prompts', () => ({ + spinner: vi.fn(() => clack.spinnerInstance), + log: clack.log, + intro: clack.intro, + note: clack.note, + outro: clack.outro, +})) + +// Only the child process is faked — everything else (fs, bundled SQL) is real. +const spawnMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ spawnSync: spawnMock })) + +const { generateDrizzleMigration } = await import('../install.js') + +const spinner = p.spinner() + +afterEach(() => { + vi.clearAllMocks() +}) + +/** + * The v2 (`eql install --drizzle`) generator. Both regressions pinned here are + * invocation-level: an unvalidated `--name` reaching a shell string, and + * `--out` being computed for the search but never handed to drizzle-kit. + */ +describe('generateDrizzleMigration', () => { + let tmp: string + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'stash-v2-drizzle-migration-')) + }) + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('rejects a migration name with unsafe characters before spawning', async () => { + await expect( + generateDrizzleMigration(spinner, { + name: 'x; rm -rf ~', + out: join(tmp, 'drizzle'), + }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith(messages.eql.migrationBadName) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it.each([ + ['command substitution', 'a$(whoami)'], + ['backticks', 'a`id`'], + ['a space', 'add eql'], + ['a path separator', '../escape'], + ])('rejects %s in --name', async (_label, name) => { + await expect( + generateDrizzleMigration(spinner, { name, out: join(tmp, 'drizzle') }), + ).rejects.toBeInstanceOf(CliExit) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects an unsafe name in a dry run too (validation precedes the preview)', async () => { + await expect( + generateDrizzleMigration(spinner, { name: 'x; ls', dryRun: true }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.note).not.toHaveBeenCalled() + }) + + it('passes --name and --out to drizzle-kit as argv (no shell) and writes the SQL', async () => { + const out = join(tmp, 'db', 'migrations') + mkdirSync(out, { recursive: true }) + // Stand in for drizzle-kit scaffolding an empty custom migration. + spawnMock.mockImplementation(() => { + writeFileSync(join(out, '0000_add-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + + await generateDrizzleMigration(spinner, { name: 'add-eql', out }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const [command, argv] = spawnMock.mock.calls[0] + // argv array, never a shell string — name/out are discrete inert tokens. + expect(typeof command).toBe('string') + expect(Array.isArray(argv)).toBe(true) + expect(argv).toContain('drizzle-kit') + expect(argv).toContain('--name=add-eql') + // DEFECT 2: --out must actually be passed, so drizzle-kit writes where we + // then look. + expect(argv).toContain(`--out=${out}`) + + const written = readFileSync(join(out, '0000_add-eql.sql'), 'utf-8') + expect(written).toContain('cs_migrations') + }) + + it('includes --out in the dry-run preview', async () => { + const out = join(tmp, 'custom-out') + await generateDrizzleMigration(spinner, { dryRun: true, out }) + expect(spawnMock).not.toHaveBeenCalled() + expect(clack.note).toHaveBeenCalledWith( + expect.stringContaining(`--out=${out}`), + 'Dry Run', + ) + }) + + it('aborts with CliExit when drizzle-kit exits non-zero', async () => { + spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' }) + await expect( + generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith('boom') + }) +}) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 74d85722..55c200c8 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { existsSync, unlinkSync, writeFileSync } from 'node:fs' import { readdir } from 'node:fs/promises' import { join, resolve } from 'node:path' @@ -8,7 +8,12 @@ import { } from '@cipherstash/migrate' import * as p from '@clack/prompts' import pg from 'pg' -import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { CliExit } from '@/cli/exit.js' +import { + detectPackageManager, + runnerArgv, + runnerCommand, +} from '@/commands/init/utils.js' import { resolveDatabaseUrl } from '@/config/database-url.js' import { findConfigFile, loadStashConfig } from '@/config/index.js' import { isInteractive } from '@/config/tty.js' @@ -37,6 +42,16 @@ import { const DEFAULT_MIGRATION_NAME = 'install-eql' const DEFAULT_DRIZZLE_OUT = 'drizzle' +/** + * File-system-safe migration name: what drizzle-kit accepts, and shell-inert. + * + * Lives here rather than in `commands/eql/migration.ts` (the v3 generator) only + * because that module already imports from this one — keeping the constant on + * this side of the existing edge avoids an import cycle. Both generators share + * it; there must not be a second copy. + */ +export const SAFE_MIGRATION_NAME = /^[\w-]+$/ + export interface InstallOptions { force?: boolean dryRun?: boolean @@ -467,8 +482,12 @@ export function printNextSteps(): void { * Uses `drizzle-kit generate --custom` to scaffold an empty migration, * then loads the EQL install SQL (bundled by default, or from GitHub with * `--latest`) and writes it into the file. + * + * Exported for unit tests; not part of the CLI's public surface. Failures + * throw {@link CliExit} rather than calling `process.exit` so the telemetry + * `finally` in `main.ts` still runs (and the branches stay testable). */ -async function generateDrizzleMigration( +export async function generateDrizzleMigration( s: ReturnType, options: { name?: string @@ -480,8 +499,29 @@ async function generateDrizzleMigration( }, ) { const migrationName = options.name ?? DEFAULT_MIGRATION_NAME + if (!SAFE_MIGRATION_NAME.test(migrationName)) { + p.log.error(messages.eql.migrationBadName) + p.outro('Migration aborted.') + throw new CliExit(1) + } const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) - const drizzleCmd = `${runnerCommand(detectPackageManager(), '').trim()} drizzle-kit generate --custom --name=${migrationName}` + + // Invoke via spawnSync with an argv array (no shell), so a `--name` carrying + // spaces or shell metacharacters is one inert token, never word-split or + // executed. `--out` is always passed so drizzle-kit WRITES where we then + // LOOK — otherwise a project whose drizzle.config.ts points elsewhere would + // have drizzle-kit write there while we search `drizzle/` and fail in step 2. + const pm = detectPackageManager() + const { command, prefixArgs } = runnerArgv(pm) + const drizzleArgs = [ + ...prefixArgs, + 'drizzle-kit', + 'generate', + '--custom', + `--name=${migrationName}`, + `--out=${outDir}`, + ] + const drizzleCmd = `${runnerCommand(pm, '').trim()} ${drizzleArgs.slice(prefixArgs.length).join(' ')}` if (options.dryRun) { p.log.info('Dry run — no changes will be made.') @@ -501,32 +541,23 @@ async function generateDrizzleMigration( // Step 1: Generate a custom Drizzle migration s.start('Generating custom Drizzle migration...') - try { - execSync(drizzleCmd, { - stdio: 'pipe', - encoding: 'utf-8', - }) - s.stop('Custom Drizzle migration generated.') - } catch (error) { + const result = spawnSync(command, drizzleArgs, { + stdio: 'pipe', + encoding: 'utf-8', + }) + if (result.status !== 0) { s.stop('Failed to generate migration.') - const stderr = - error !== null && - typeof error === 'object' && - 'stderr' in error && - typeof error.stderr === 'string' - ? error.stderr.trim() - : undefined - if (stderr) { - p.log.error(stderr) - } else { - p.log.error( - error instanceof Error ? error.message : 'Unknown error occurred.', - ) - } + const stderr = result.stderr?.trim() + p.log.error( + stderr || + result.error?.message || + `drizzle-kit exited with status ${result.status ?? 'unknown'}.`, + ) p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit') p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } + s.stop('Custom Drizzle migration generated.') // Step 2: Find the generated migration file s.start('Locating generated migration file...') @@ -537,8 +568,11 @@ async function generateDrizzleMigration( } catch (error) { s.stop('Failed to locate migration file.') p.log.error(error instanceof Error ? error.message : String(error)) + p.log.info( + 'If your drizzle.config.ts writes elsewhere, pass --out so it matches.', + ) p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } // Step 3: Load the EQL SQL (bundled or from GitHub). Thread supabase / @@ -560,7 +594,7 @@ async function generateDrizzleMigration( p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } } else { s.start('Loading bundled EQL install script...') @@ -572,7 +606,7 @@ async function generateDrizzleMigration( p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } } @@ -592,7 +626,7 @@ async function generateDrizzleMigration( p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } // Step 5: Sweep for sibling migrations that drizzle-kit may have emitted diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 19223ec2..be1fc355 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -8,6 +8,7 @@ import { cleanupMigrationFile, findGeneratedMigration, printNextSteps, + SAFE_MIGRATION_NAME, } from '@/commands/db/install.js' import { detectPackageManager, @@ -20,9 +21,6 @@ import { messages } from '@/messages.js' const DEFAULT_MIGRATION_NAME = 'install-eql' const DEFAULT_DRIZZLE_OUT = 'drizzle' -/** File-system-safe migration name: what drizzle-kit accepts, and shell-inert. */ -const SAFE_MIGRATION_NAME = /^[\w-]+$/ - export interface EqlMigrationOptions { /** Emit a Drizzle custom migration. */ drizzle?: boolean diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 2cf0d28e..9f595ce1 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -1,4 +1,5 @@ import * as p from '@clack/prompts' +import { CliExit } from '../../../cli/exit.js' import { isInteractive } from '../../../config/tty.js' import { pinnedSpec } from '../../../runtime-versions.js' import { installCommand } from '../../db/install.js' @@ -105,7 +106,12 @@ export const installEqlStep: InitStep = { // config scaffolded — this is NOT a one-shot `--database-url` run. scaffoldConfig: 'ensure', }) - } catch { + } catch (error) { + // A cooperative exit is a hard stop the installer already reported on + // (it printed its own actionable error and outro). Re-throw so it + // unwinds to `run()` and exits, rather than being reframed below as a + // database-connection problem and letting init continue. + if (error instanceof CliExit) throw error // Don't echo the underlying error — Postgres client errors routinely // include the connection string (with credentials) in the message, // and `state.databaseUrl` flows into this code path. diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 8fe14a53..bd0b78d8 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -350,7 +350,7 @@ Gets a project from zero to installed EQL. It loads an existing `stash.config.ts | `--force` | Reinstall even if EQL is present | | `--dry-run` | Show what would happen | | `--supabase` | Supabase-compatible install (no operator families; grants `anon`, `authenticated`, `service_role`) | -| `--drizzle` | Generate a Drizzle migration (`--name`, `--out` tune it) | +| `--drizzle` | Generate a Drizzle migration (`--name`, `--out` tune it — `--name` accepts letters, numbers, `-`, `_` only; `--out` is passed to `drizzle-kit --out`, so set it to match your `drizzle.config.ts`) | | `--migration` / `--direct` | Supabase: write a migration file, or run SQL directly | | `--migrations-dir ` | Supabase migrations directory (default `supabase/migrations`) | | `--exclude-operator-family` | Skip operator families (non-superuser roles) | @@ -382,8 +382,8 @@ stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authentic | `--drizzle` | Emit a Drizzle custom migration (via `drizzle-kit generate --custom`, then inject the SQL). Requires `drizzle-kit`. | | `--prisma` | Emit a Prisma Next migration. **Not available yet** — the emitter is a follow-up (tracked in GitHub issue #690); fails with a pointer for now. Use `--drizzle` today. | | `--supabase` | Append the Supabase role grants (`eql_v3` + `eql_v3_internal` → `anon`, `authenticated`, `service_role`). Harmless when you connect directly as `postgres`; needed when the same tables are reached via PostgREST/RLS. | -| `--name ` | Migration name (Drizzle). Default `install-eql`. | -| `--out ` | Output directory (Drizzle). Default `drizzle`. | +| `--name ` | Migration name (Drizzle). Default `install-eql`. Letters, numbers, `-`, and `_` only — anything else is rejected. | +| `--out ` | Output directory (Drizzle). Default `drizzle`. Passed straight to `drizzle-kit --out`, so set it to match your `drizzle.config.ts` if that writes elsewhere. | | `--dry-run` | Show what would happen without writing anything. | Pass exactly one of `--drizzle` / `--prisma`. The generated migration also installs the `cs_migrations` tracking schema, so one `drizzle-kit migrate` covers everything `stash encrypt …` needs. From 39bd7067b84fda2089800a3703f3366776f89594 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 20 Jul 2026 13:05:42 +1000 Subject: [PATCH 2/4] test(cli): cover the CliExit re-throw and pin the drizzle-kit argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the v2 Drizzle migration generator fix. The `instanceof CliExit` re-throw in `init/steps/install-eql.ts` was the one edit outside the two main files and had no coverage. Without it a hard stop the installer already reported on gets reframed as "check your database connection" and init continues past it. Add a test for the re-throw plus its companion — an ordinary throw still being swallowed so init carries on — since the pair is what makes either meaningful. Tighten the argv assertion from `typeof command === 'string'` and `toContain` to a full `toEqual`. The loose form passed even with the runner prefix dropped, which would run drizzle-kit under the wrong resolver. `detectPackageManager` is pinned so the assertion is deterministic (detection reads the cwd lockfile and npm_config_user_agent); the runner mapping itself stays real. Also correct the stale doc comment on `installEqlStep` — `installCommand` now ends init via `process.exit(1)` or a thrown `CliExit`, not only the former. Verified by revert: removing the re-throw fails 1 of 7; dropping the `dlx` prefix fails 1 of 9. Suite: 52 files, 594 tests. code:check clean. --- .../generate-drizzle-migration.test.ts | 31 ++++++++++++----- .../init/steps/__tests__/install-eql.test.ts | 33 +++++++++++++++++++ .../src/commands/init/steps/install-eql.ts | 10 +++--- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts index 15ec0f2b..f6eef631 100644 --- a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts +++ b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts @@ -33,6 +33,15 @@ vi.mock('@clack/prompts', () => ({ const spawnMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ spawnSync: spawnMock })) +// Pin the package manager so the argv assertion below is exact. Detection reads +// the lockfile in cwd and npm_config_user_agent, both of which vary by how the +// suite was launched. The runner MAPPING stays real — `pnpm` + `['dlx', …]` is +// part of what's being asserted, so mocking it would defeat the test. +vi.mock('@/commands/init/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + detectPackageManager: () => 'pnpm', +})) + const { generateDrizzleMigration } = await import('../install.js') const spinner = p.spinner() @@ -98,14 +107,20 @@ describe('generateDrizzleMigration', () => { expect(spawnMock).toHaveBeenCalledTimes(1) const [command, argv] = spawnMock.mock.calls[0] - // argv array, never a shell string — name/out are discrete inert tokens. - expect(typeof command).toBe('string') - expect(Array.isArray(argv)).toBe(true) - expect(argv).toContain('drizzle-kit') - expect(argv).toContain('--name=add-eql') - // DEFECT 2: --out must actually be passed, so drizzle-kit writes where we - // then look. - expect(argv).toContain(`--out=${out}`) + // The whole argv, exactly — not `toContain` checks, which would still pass + // if the runner prefix (`dlx`) were dropped and drizzle-kit ran under the + // wrong resolver. DEFECT 1: name and out are discrete inert tokens in an + // array, never interpolated into a shell string. DEFECT 2: `--out` is + // actually passed, so drizzle-kit writes where step 2 then looks. + expect(command).toBe('pnpm') + expect(argv).toEqual([ + 'dlx', + 'drizzle-kit', + 'generate', + '--custom', + '--name=add-eql', + `--out=${out}`, + ]) const written = readFileSync(join(out, '0000_add-eql.sql'), 'utf-8') expect(written).toContain('cs_migrations') 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 dfa3efbf..7dace897 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 @@ -19,6 +19,7 @@ vi.mock('@clack/prompts', () => ({ })) import * as p from '@clack/prompts' +import { CliExit } from '../../../../cli/exit.js' import { isInteractive } from '../../../../config/tty.js' import { installCommand } from '../../../db/install.js' import { installEqlStep } from '../install-eql.js' @@ -103,4 +104,36 @@ describe('installEqlStep', () => { expect(result.eqlInstalled).toBe(false) expect(result.eqlMigrationPending).toBe(true) }) + + it('re-throws CliExit instead of reframing it as a connection failure', async () => { + // `installCommand` throws CliExit for hard stops it has ALREADY reported on + // with its own actionable error (e.g. an unsafe `--name`). The broad catch + // below it must not swallow that: doing so prints "check your database + // connection" for a problem that has nothing to do with the database, and + // lets init continue past a hard stop. Re-throwing unwinds to `run()`, + // which records the outcome and exits with the carried code. + vi.mocked(installCommand).mockRejectedValueOnce(new CliExit(1)) + + await expect( + installEqlStep.run(baseState, provider), + ).rejects.toBeInstanceOf(CliExit) + expect(p.log.error).not.toHaveBeenCalled() + }) + + it('still swallows a non-CliExit failure and lets init continue', async () => { + // The contrast that gives the test above its meaning: an ordinary throw is + // reported generically and init carries on. The message is deliberately + // generic — Postgres client errors routinely carry the connection string, + // credentials included, so the underlying error is never echoed. + vi.mocked(installCommand).mockRejectedValueOnce( + new Error('connect ECONNREFUSED'), + ) + + const result = await installEqlStep.run(baseState, provider) + + expect(result.eqlInstalled).toBe(false) + expect(p.log.error).toHaveBeenCalledWith( + 'EQL install failed — check your database connection and try again.', + ) + }) }) diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 9f595ce1..13b87e46 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -20,10 +20,12 @@ import { isPackageInstalled } from '../utils.js' * picks the Supabase migration / direct mode itself based on `--supabase` and * project layout — we don't pre-decide it here. * - * `installCommand` may `process.exit(1)` on a hard failure (mutually-exclusive - * flag clash, scaffold cancellation). That's fine — by that point the user - * has already authenticated and written the encryption client, and a clean - * exit is preferable to a half-installed setup. + * `installCommand` ends init on a hard failure (mutually-exclusive flag clash, + * scaffold cancellation, an unsafe `--name`) — either by calling + * `process.exit(1)` directly or by throwing `CliExit`, which the catch below + * re-throws. That's fine — by that point the user has already authenticated + * and written the encryption client, and a clean exit is preferable to a + * half-installed setup. */ export const installEqlStep: InitStep = { id: 'install-eql', From 8a6a0a75fb6352720b22d89bae6f33fb92855e3b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Jul 2026 11:15:26 +1000 Subject: [PATCH 3/4] test(cli): cover v2 drizzle generator failure paths; document dlx vs execArgv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the @auxesis review threads on #703 — all test-coverage gaps on the v2 generateDrizzleMigration failure arms, plus one documentation gap: - write-failure cleanup (delegating writeFileSync spy) - --latest fetch-failure cleanup (stubbed downloadEqlSql) - the --out remediation hint when the migration isn't where we looked - the defaulted --out arm (flag omitted -> /drizzle) via dry-run - the ENOENT and exit-status arms of the error fallback chain - --name '' rejection boundary (empty is not nullish) Also comment why v2 keeps the download-and-run form (dlx) while v3 uses the project-local execArgv form, so the pinned-argv divergence is a conscious contract. --- .../generate-drizzle-migration.test.ts | 151 +++++++++++++++++- packages/cli/src/commands/db/install.ts | 10 ++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts index f6eef631..e12b66af 100644 --- a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts +++ b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts @@ -1,4 +1,5 @@ import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -6,7 +7,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import * as p from '@clack/prompts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CliExit } from '../../../cli/exit.js' @@ -33,6 +34,32 @@ vi.mock('@clack/prompts', () => ({ const spawnMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ spawnSync: spawnMock })) +// node:fs stays REAL by default — the generator's own writes, the bundled-SQL +// reads, and cleanup all need it. We only need a seam on `writeFileSync` to +// simulate the SQL-bundle write failing, so route it through a spy that +// delegates to the real implementation (reset in beforeEach) and is overridden +// only in the write-failure test. +const fsWrite = vi.hoisted(() => ({ + real: (() => { + throw new Error('fsWrite.real not initialised') + }) as typeof import('node:fs').writeFileSync, + spy: vi.fn(), +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsWrite.real = actual.writeFileSync + return { ...actual, default: actual, writeFileSync: fsWrite.spy } +}) + +// The `--latest` path calls `downloadEqlSql`; stub just that export so we can +// make the network fetch reject. Everything else in the installer module +// (including the real `loadBundledEqlSql` the other tests rely on) stays real. +const downloadMock = vi.hoisted(() => vi.fn()) +vi.mock('@/installer/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + downloadEqlSql: downloadMock, +})) + // Pin the package manager so the argv assertion below is exact. Detection reads // the lockfile in cwd and npm_config_user_agent, both of which vary by how the // suite was launched. The runner MAPPING stays real — `pnpm` + `['dlx', …]` is @@ -46,6 +73,12 @@ const { generateDrizzleMigration } = await import('../install.js') const spinner = p.spinner() +beforeEach(() => { + // Default: `writeFileSync` behaves exactly like the real thing. Tests that + // need it to fail override the implementation for their own scope. + fsWrite.spy.mockImplementation(fsWrite.real) +}) + afterEach(() => { vi.clearAllMocks() }) @@ -80,6 +113,10 @@ describe('generateDrizzleMigration', () => { ['backticks', 'a`id`'], ['a space', 'add eql'], ['a path separator', '../escape'], + // `''` is not nullish, so it slips past `options.name ?? DEFAULT` and hits + // the regex, where `+` rejects it — `--name ''` aborts, it does NOT fall + // back to `install-eql`. The one input where "empty" and "absent" diverge. + ['an empty string', ''], ])('rejects %s in --name', async (_label, name) => { await expect( generateDrizzleMigration(spinner, { name, out: join(tmp, 'drizzle') }), @@ -143,4 +180,116 @@ describe('generateDrizzleMigration', () => { ).rejects.toBeInstanceOf(CliExit) expect(clack.log.error).toHaveBeenCalledWith('boom') }) + + it('reports the spawn error when drizzle-kit cannot be launched', async () => { + // spawnSync's ENOENT shape: null status, no captured stderr, `error` set. + // `result.stderr?.trim()` is undefined, so the message falls through to the + // second arm (`result.error?.message`) — the realistic "drizzle-kit isn't + // installed" case. If the `?.` on stderr were dropped this shape would + // throw a TypeError instead of reporting. + spawnMock.mockReturnValue({ + status: null, + stdout: null, + stderr: null, + error: Object.assign(new Error('spawnSync pnpm ENOENT'), { + code: 'ENOENT', + }), + }) + + await expect( + generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith('spawnSync pnpm ENOENT') + expect(clack.log.info).toHaveBeenCalledWith( + 'Make sure drizzle-kit is installed: npm install -D drizzle-kit', + ) + }) + + it('falls back to the exit status when there is no stderr or spawn error', async () => { + // Third arm of the fallback chain: non-zero status, empty stderr, no + // `error`. The user still gets a message instead of a blank error line. + spawnMock.mockReturnValue({ status: 2, stdout: '', stderr: '' }) + + await expect( + generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith( + 'drizzle-kit exited with status 2.', + ) + }) + + it('defaults --out to an absolute drizzle/ when the flag is omitted', async () => { + // Widest blast radius in the diff: because `--out` is always appended, a + // flag-less invocation has its drizzle.config.ts `out` overridden by + // `/drizzle`. The dry-run preview reaches that arm without touching + // the filesystem or spawning. + await generateDrizzleMigration(spinner, { dryRun: true }) + expect(clack.note).toHaveBeenCalledWith( + expect.stringContaining(`--out=${resolve('drizzle')}`), + 'Dry Run', + ) + }) + + it('points at --out when the generated migration is not where we looked', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + // drizzle-kit "succeeds" but wrote to its drizzle.config.ts `out`, not ours, + // so step 2 finds nothing and aborts with the remediation hint (defect 2). + spawnMock.mockReturnValue({ status: 0, stdout: '', stderr: '' }) + + await expect( + generateDrizzleMigration(spinner, { out }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.info).toHaveBeenCalledWith( + 'If your drizzle.config.ts writes elsewhere, pass --out so it matches.', + ) + }) + + it('removes the scaffolded migration when writing the SQL fails', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + const scaffolded = join(out, '0000_install-eql.sql') + // drizzle-kit scaffolds an empty custom migration where we look. + spawnMock.mockImplementation(() => { + fsWrite.real(scaffolded, '') + return { status: 0, stdout: '', stderr: '' } + }) + // Throw only on the big bundle write, letting the empty scaffold through. + fsWrite.spy.mockImplementation(((path, data, ...rest) => { + if (typeof data === 'string' && data.includes('cs_migrations')) { + throw new Error('EACCES: permission denied') + } + return fsWrite.real( + path as string, + data as string, + ...(rest as unknown[]), + ) + }) as typeof import('node:fs').writeFileSync) + + await expect( + generateDrizzleMigration(spinner, { out }), + ).rejects.toBeInstanceOf(CliExit) + // Without cleanup, an empty scaffolded migration survives and + // `drizzle-kit migrate` applies and records it — a silent no-op migration. + expect(existsSync(scaffolded)).toBe(false) + expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied') + }) + + it('cleans up the scaffold when --latest cannot fetch the SQL', async () => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + const scaffolded = join(out, '0000_install-eql.sql') + spawnMock.mockImplementation(() => { + fsWrite.real(scaffolded, '') + return { status: 0, stdout: '', stderr: '' } + }) + // A network failure (rate limit / offline) is the likeliest real trip here. + downloadMock.mockRejectedValueOnce(new Error('403 rate limited')) + + await expect( + generateDrizzleMigration(spinner, { out, latest: true }), + ).rejects.toBeInstanceOf(CliExit) + expect(existsSync(scaffolded)).toBe(false) + expect(clack.log.error).toHaveBeenCalledWith('403 rate limited') + }) }) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 55c200c8..bb8b0533 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -511,6 +511,16 @@ export async function generateDrizzleMigration( // executed. `--out` is always passed so drizzle-kit WRITES where we then // LOOK — otherwise a project whose drizzle.config.ts points elsewhere would // have drizzle-kit write there while we search `drizzle/` and fail in step 2. + // + // Runner: `runnerArgv` is the download-and-run form (`pnpm dlx` / `npx` / + // `bunx`) this v2 path has always used. The v3 generator + // (`commands/eql/migration.ts`) deliberately uses `execArgv` instead + // (`pnpm exec` / `npx --no-install`) so it resolves THIS project's local + // drizzle-kit and drizzle.config.ts. v2 keeps `dlx` to preserve established + // behaviour for existing users; aligning it with v3's project-local form is + // a deliberate, separate change, not made here. The exact argv (including + // `dlx`) is pinned in the tests so this divergence stays a conscious + // contract rather than drifting silently. const pm = detectPackageManager() const { command, prefixArgs } = runnerArgv(pm) const drizzleArgs = [ From 62b41d14494bd6351ddad7874dcd882b95ab29f8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Jul 2026 11:45:08 +1000 Subject: [PATCH 4/4] fix(cli): run v2 drizzle-kit project-locally (execArgv), matching v3 Switch the v2 generateDrizzleMigration invocation from the download-and-run form (runnerArgv -> pnpm dlx / npx / bunx) to the project-local form (execArgv -> pnpm exec / npx --no-install), so drizzle-kit resolves THIS project's drizzle.config.ts and schema and fails loudly if drizzle-kit is missing instead of surprise-downloading a possibly-different major. The dry-run preview (execCommand) and the drizzle-kit migrate hint follow suit. Updates the pinned-argv test from 'dlx' to 'exec'. Resolves review thread T2 by making the recommended change rather than documenting the divergence. --- .changeset/khaki-pugs-play.md | 3 +- .../generate-drizzle-migration.test.ts | 10 +++--- packages/cli/src/commands/db/install.ts | 34 ++++++++----------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.changeset/khaki-pugs-play.md b/.changeset/khaki-pugs-play.md index 2490b045..6e31b7e9 100644 --- a/.changeset/khaki-pugs-play.md +++ b/.changeset/khaki-pugs-play.md @@ -6,5 +6,6 @@ Fix two defects in the Drizzle migration generator used by `stash eql install -- - **`--name` is now validated and no longer reaches a shell.** The migration name was interpolated into a shell command string, so a name containing shell metacharacters (e.g. `--name 'x; rm -rf ~'`) was executed. `--name` is now restricted to letters, numbers, dashes, and underscores, and drizzle-kit is invoked with an argv array instead of a shell string. - **`--out` is now actually passed to drizzle-kit.** The flag was used to search for the generated migration but never handed to `drizzle-kit generate`, so any project whose `drizzle.config.ts` writes migrations outside `drizzle/` had the file written in one place and searched for in another, failing with "migration file not found". +- **drizzle-kit now runs project-locally.** The generator invoked drizzle-kit through the download-and-run form (`pnpm dlx` / `npx ` / `bunx`), which could fetch a different drizzle-kit major into a temp store and resolve a different `drizzle.config.ts`/schema than the project's. It now uses the project-local form (`pnpm exec` / `npx --no-install`), so it resolves the project's own drizzle-kit and config and fails loudly if drizzle-kit isn't installed rather than surprise-downloading it. The "run your migrations" hint matches. This aligns v2 with the v3 generator's behaviour. -`stash eql migration --drizzle` (EQL v3) already had both fixes and is unchanged. +`stash eql migration --drizzle` (EQL v3) already had all three fixes and is unchanged. diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts index e12b66af..3bfc0380 100644 --- a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts +++ b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts @@ -62,7 +62,7 @@ vi.mock('@/installer/index.js', async (importOriginal) => ({ // Pin the package manager so the argv assertion below is exact. Detection reads // the lockfile in cwd and npm_config_user_agent, both of which vary by how the -// suite was launched. The runner MAPPING stays real — `pnpm` + `['dlx', …]` is +// suite was launched. The runner MAPPING stays real — `pnpm` + `['exec', …]` is // part of what's being asserted, so mocking it would defeat the test. vi.mock('@/commands/init/utils.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -145,13 +145,15 @@ describe('generateDrizzleMigration', () => { expect(spawnMock).toHaveBeenCalledTimes(1) const [command, argv] = spawnMock.mock.calls[0] // The whole argv, exactly — not `toContain` checks, which would still pass - // if the runner prefix (`dlx`) were dropped and drizzle-kit ran under the + // if the runner prefix (`exec`) were dropped and drizzle-kit ran under the // wrong resolver. DEFECT 1: name and out are discrete inert tokens in an // array, never interpolated into a shell string. DEFECT 2: `--out` is - // actually passed, so drizzle-kit writes where step 2 then looks. + // actually passed, so drizzle-kit writes where step 2 then looks. The + // project-local `exec` form (not `dlx`) is asserted so a regression back to + // download-and-run — which resolves a different drizzle.config.ts — fails. expect(command).toBe('pnpm') expect(argv).toEqual([ - 'dlx', + 'exec', 'drizzle-kit', 'generate', '--custom', diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index bb8b0533..2d4b9db9 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -11,7 +11,8 @@ import pg from 'pg' import { CliExit } from '@/cli/exit.js' import { detectPackageManager, - runnerArgv, + execArgv, + execCommand, runnerCommand, } from '@/commands/init/utils.js' import { resolveDatabaseUrl } from '@/config/database-url.js' @@ -506,23 +507,18 @@ export async function generateDrizzleMigration( } const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) - // Invoke via spawnSync with an argv array (no shell), so a `--name` carrying - // spaces or shell metacharacters is one inert token, never word-split or - // executed. `--out` is always passed so drizzle-kit WRITES where we then - // LOOK — otherwise a project whose drizzle.config.ts points elsewhere would - // have drizzle-kit write there while we search `drizzle/` and fail in step 2. - // - // Runner: `runnerArgv` is the download-and-run form (`pnpm dlx` / `npx` / - // `bunx`) this v2 path has always used. The v3 generator - // (`commands/eql/migration.ts`) deliberately uses `execArgv` instead - // (`pnpm exec` / `npx --no-install`) so it resolves THIS project's local - // drizzle-kit and drizzle.config.ts. v2 keeps `dlx` to preserve established - // behaviour for existing users; aligning it with v3's project-local form is - // a deliberate, separate change, not made here. The exact argv (including - // `dlx`) is pinned in the tests so this divergence stays a conscious - // contract rather than drifting silently. + // Run the PROJECT-LOCAL drizzle-kit (`pnpm exec` / `npx --no-install`), not + // the download-and-run form (`pnpm dlx`) — it must resolve THIS project's + // drizzle.config.ts and schema, and `--no-install` fails loudly instead of + // surprise-downloading a possibly-different drizzle-kit major. Matches the v3 + // generator (`commands/eql/migration.ts`). Invoke via spawnSync with an argv + // array (no shell), so a `--name` carrying spaces or shell metacharacters is + // one inert token, never word-split or executed. `--out` is always passed so + // drizzle-kit WRITES where we then LOOK — otherwise a project whose + // drizzle.config.ts points elsewhere would have drizzle-kit write there while + // we search `drizzle/` and fail in step 2. const pm = detectPackageManager() - const { command, prefixArgs } = runnerArgv(pm) + const { command, prefixArgs } = execArgv(pm) const drizzleArgs = [ ...prefixArgs, 'drizzle-kit', @@ -531,7 +527,7 @@ export async function generateDrizzleMigration( `--name=${migrationName}`, `--out=${outDir}`, ] - const drizzleCmd = `${runnerCommand(pm, '').trim()} ${drizzleArgs.slice(prefixArgs.length).join(' ')}` + const drizzleCmd = `${execCommand(pm)} ${drizzleArgs.slice(prefixArgs.length).join(' ')}` if (options.dryRun) { p.log.info('Dry run — no changes will be made.') @@ -662,7 +658,7 @@ export async function generateDrizzleMigration( p.log.success(`Migration created: ${generatedMigrationPath}`) p.note( - `Run your Drizzle migrations to install EQL:\n\n ${runnerCommand(detectPackageManager(), '').trim()} drizzle-kit migrate`, + `Run your Drizzle migrations to install EQL:\n\n ${execCommand(detectPackageManager())} drizzle-kit migrate`, 'Next Steps', ) printNextSteps()