From 01d93816423ec8ec834958a10fcd55057a2d87ff Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 22:33:53 +1000 Subject: [PATCH 1/5] feat(cli): add `stash eql migration --drizzle` (v3, migration-first EQL install) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the three PRs in #690: make migration-first, ORM-agnostic EQL v3 install the front door, so every integration sources install SQL from one place (the CLI's bundled variants) instead of vendoring its own. This is the pattern that keeps Drizzle Supabase-safe; prisma-next's superuser-on-Supabase failure is fixed by the stacked follow-ups (`--prisma` emitter + removing prisma-next's baked baseline), which land on top of the prisma-next EQL v3 work (#655). `stash eql migration --drizzle [--supabase] [--name] [--out] [--dry-run]`: - Generates a Drizzle custom migration (via `drizzle-kit generate --custom`, then injects the SQL) carrying the bundled EQL **v3** install script + the `cs_migrations` tracking schema — one `drizzle-kit migrate` does everything `stash encrypt …` needs. - `--supabase` appends the v3 role grants (`eql_v3` + `eql_v3_internal` → anon/authenticated/service_role), matching `stash eql install --supabase`. - v3 only — no `--eql-version` (prisma-next never shipped v2). - `--prisma` is registered but fails with a pointer to #690 until the stacked PR. The SQL assembly (`buildEqlV3MigrationSql`) is a pure, unit-tested function; the drizzle-kit scaffold/inject orchestration reuses the proven helpers from the v2 `install --drizzle` path (`findGeneratedMigration`, `cleanupMigrationFile`, now exported). Registry + dispatch + help + `stash-cli`/`stash-drizzle` skills updated. Tests: 4 new unit (bundle present, grants gated on --supabase, tracking schema), 544 CLI unit green, manifest resolves the command, biome clean. Changeset: stash minor. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-migration-command.md | 21 +++ packages/cli/src/bin/main.ts | 14 ++ packages/cli/src/cli/registry.ts | 39 ++++ packages/cli/src/commands/db/install.ts | 4 +- .../commands/eql/__tests__/migration.test.ts | 46 +++++ packages/cli/src/commands/eql/migration.ts | 169 ++++++++++++++++++ packages/cli/src/messages.ts | 14 ++ skills/stash-cli/SKILL.md | 23 ++- skills/stash-drizzle/SKILL.md | 13 +- 9 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 .changeset/eql-migration-command.md create mode 100644 packages/cli/src/commands/eql/__tests__/migration.test.ts create mode 100644 packages/cli/src/commands/eql/migration.ts diff --git a/.changeset/eql-migration-command.md b/.changeset/eql-migration-command.md new file mode 100644 index 00000000..09ff25e2 --- /dev/null +++ b/.changeset/eql-migration-command.md @@ -0,0 +1,21 @@ +--- +'stash': minor +--- + +Add `stash eql migration` — generate an EQL **v3** install migration for your ORM +instead of running the SQL directly against the database (`stash eql install`). +Migration-first is the preferred path: the install lands in your migration history +and ships to every environment through the ORM's own migrate step. + +```bash +stash eql migration --drizzle # Drizzle custom migration +stash eql migration --drizzle --supabase # also grants eql_v3 to anon/authenticated/service_role +``` + +The migration carries the CLI's bundled v3 install SQL (one source of truth) plus +the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers +everything `stash encrypt …` needs. `--supabase` appends the `eql_v3` + +`eql_v3_internal` role grants for PostgREST/RLS access. + +`--prisma` is registered but not available yet — it ships with prisma-next EQL v3 +support and fails with a pointer until then. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index b7703df7..7d9af38c 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -232,6 +232,20 @@ async function runEqlCommand( case 'install': await runInstall(flags, values) break + case 'migration': { + const { eqlMigrationCommand } = await import( + '../commands/eql/migration.js' + ) + await eqlMigrationCommand({ + drizzle: flags.drizzle, + prisma: flags.prisma, + supabase: flags.supabase, + name: values.name, + out: values.out, + dryRun: flags['dry-run'], + }) + break + } case 'upgrade': await runUpgrade(flags, values) break diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 1f639dc9..67680a37 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -377,6 +377,45 @@ export const registry: CommandGroup[] = [ DATABASE_URL_FLAG, ], }, + { + name: 'eql migration', + summary: + 'Generate an EQL v3 install migration for your ORM (Drizzle now; Prisma Next soon)', + examples: [ + 'eql migration --drizzle', + 'eql migration --drizzle --supabase', + ], + flags: [ + { + name: '--drizzle', + description: + 'Emit a Drizzle custom migration containing the EQL v3 install SQL.', + }, + { + name: '--prisma', + description: + 'Emit a Prisma Next migration (ships with prisma-next EQL v3 support; not available yet).', + }, + { + name: '--supabase', + description: + 'Append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role).', + }, + { + name: '--name', + value: '', + description: + 'Name for the generated migration (Drizzle). Defaults to `install-eql`.', + }, + { + name: '--out', + value: '', + description: + 'Directory to write the generated migration into (Drizzle). Defaults to `drizzle`.', + }, + DRY_RUN_FLAG, + ], + }, { name: 'eql upgrade', summary: 'Upgrade EQL extensions to the latest version', diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 7ce8f6aa..1127bcf2 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -887,7 +887,7 @@ async function writeSupabaseMigrationFile( * Find the most recently generated migration file matching the given name. * Drizzle-kit generates flat SQL files like `0000_install-eql.sql`. */ -async function findGeneratedMigration( +export async function findGeneratedMigration( outDir: string, migrationName: string, ): Promise { @@ -915,7 +915,7 @@ async function findGeneratedMigration( /** * Attempt to clean up a generated migration file on failure. */ -function cleanupMigrationFile(filePath: string | undefined): void { +export function cleanupMigrationFile(filePath: string | undefined): void { if (!filePath) return try { diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts new file mode 100644 index 00000000..37cfa2ab --- /dev/null +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { buildEqlV3MigrationSql } from '../migration.js' + +/** + * `buildEqlV3MigrationSql` is the pure core of `stash eql migration --drizzle`: + * it assembles the migration contents from the CLI's bundled v3 install SQL, + * the optional Supabase grants, and the `cs_migrations` tracking schema. The + * file-writing orchestration around it (drizzle-kit scaffold + inject) is thin + * and I/O-bound, so the assembly is where the contract lives. + */ +describe('buildEqlV3MigrationSql', () => { + it('emits the EQL v3 install bundle and the cs_migrations tracking schema', () => { + const sql = buildEqlV3MigrationSql({ supabase: false }) + // v3 bundle, not v2 — the whole point of the command. + expect(sql).toContain('EQL v3 schema creation') + expect(sql).toContain('eql_v3') + // Bundled tracking schema so one migration run is enough for `stash encrypt`. + expect(sql).toContain('cs_migrations') + }) + + it('omits the Supabase role grants without --supabase', () => { + const sql = buildEqlV3MigrationSql({ supabase: false }) + expect(sql).not.toContain('TO anon, authenticated, service_role') + expect(sql).not.toContain('-- Supabase role grants') + }) + + it('appends the eql_v3 + eql_v3_internal grants with --supabase', () => { + const sql = buildEqlV3MigrationSql({ supabase: true }) + expect(sql).toContain('-- Supabase role grants') + expect(sql).toContain( + 'GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role', + ) + expect(sql).toContain( + 'GRANT USAGE ON SCHEMA eql_v3_internal TO anon, authenticated, service_role', + ) + }) + + it('is a superset of the non-supabase content when --supabase is set', () => { + const base = buildEqlV3MigrationSql({ supabase: false }) + const supa = buildEqlV3MigrationSql({ supabase: true }) + // The grants are additive: everything in the base still appears. + expect(supa.length).toBeGreaterThan(base.length) + expect(supa).toContain('EQL v3 schema creation') + expect(supa).toContain('cs_migrations') + }) +}) diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts new file mode 100644 index 00000000..404780fc --- /dev/null +++ b/packages/cli/src/commands/eql/migration.ts @@ -0,0 +1,169 @@ +import { execSync } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { MIGRATIONS_SCHEMA_SQL } from '@cipherstash/migrate' +import * as p from '@clack/prompts' +import { + cleanupMigrationFile, + findGeneratedMigration, +} from '@/commands/db/install.js' +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { loadBundledEqlSql, supabaseGrantsFor } from '@/installer/index.js' +import { messages } from '@/messages.js' + +const DEFAULT_MIGRATION_NAME = 'install-eql' +const DEFAULT_DRIZZLE_OUT = 'drizzle' + +export interface EqlMigrationOptions { + /** Emit a Drizzle custom migration. */ + drizzle?: boolean + /** Emit a Prisma Next migration (not yet available — see issue #690). */ + prisma?: boolean + /** Append the Supabase role grants (`eql_v3` + `eql_v3_internal`). */ + supabase?: boolean + /** Migration name (Drizzle). Defaults to `install-eql`. */ + name?: string + /** Output directory (Drizzle). Defaults to `drizzle`. */ + out?: string + /** Describe what would happen without writing anything. */ + dryRun?: boolean +} + +/** + * Assemble the EQL **v3** install SQL for a generated migration. + * + * One source of truth: the SQL is the CLI's bundled v3 install script + * (`loadBundledEqlSql({ eqlVersion: 3 })`) — the same bundle `stash eql install` + * applies directly. On `--supabase` the v3 role grants are appended + * (`supabaseGrantsFor(3)` → USAGE/EXECUTE on `eql_v3` + `eql_v3_internal` for + * `anon`/`authenticated`/`service_role`), matching `stash eql install --supabase`. + * Apps that connect directly as `postgres` don't need the grants, but they're + * idempotent and harmless, and required when the same tables are reached via + * PostgREST/RLS. + * + * The `cs_migrations` tracking schema is bundled in so a single migration run + * installs everything `stash encrypt …` needs — no out-of-band `stash eql + * install`. + */ +export function buildEqlV3MigrationSql(opts: { supabase: boolean }): string { + const eqlSql = loadBundledEqlSql({ eqlVersion: 3 }) + const grants = opts.supabase + ? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${supabaseGrantsFor(3).trim()}` + : '' + return `${eqlSql.trim()}${grants}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n` +} + +/** + * `stash eql migration` — generate an EQL v3 install migration for the target + * ORM, rather than running SQL directly against the database (that's `stash eql + * install`). Migration-first is the preferred path: the install lands in the + * project's migration history and ships to every environment through the ORM's + * own migrate step. + * + * v3 only — there is no `--eql-version` here. prisma-next never shipped v2, and + * the Drizzle v3 surface is the documented one. + */ +export async function eqlMigrationCommand( + options: EqlMigrationOptions, +): Promise { + const targets = [ + options.drizzle && 'drizzle', + options.prisma && 'prisma', + ].filter(Boolean) + if (targets.length === 0) { + p.log.error(messages.eql.migrationNeedsTarget) + process.exit(1) + } + if (targets.length > 1) { + p.log.error(messages.eql.migrationOneTarget) + process.exit(1) + } + + if (options.prisma) { + // The prisma-next emitter ships stacked on the prisma-next EQL v3 work + // (PR #655); it can't install a v3 schema that doesn't exist on that + // surface yet. Fail loudly with a pointer rather than emit a broken file. + p.log.error(messages.eql.migrationPrismaUnavailable) + process.exit(1) + } + + await generateDrizzleEqlMigration(options) +} + +async function generateDrizzleEqlMigration( + options: EqlMigrationOptions, +): Promise { + const migrationName = options.name ?? DEFAULT_MIGRATION_NAME + const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) + const runner = runnerCommand(detectPackageManager(), '').trim() + const drizzleCmd = `${runner} drizzle-kit generate --custom --name=${migrationName}` + + const sql = buildEqlV3MigrationSql({ supabase: options.supabase ?? false }) + + if (options.dryRun) { + p.note( + `Would run: ${drizzleCmd}\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.') + return + } + + const s = p.spinner() + + // Step 1 — scaffold an empty custom migration (drizzle-kit owns the journal + // + sequence numbering; hand-rolling that is fragile). + s.start('Generating custom Drizzle migration...') + try { + execSync(drizzleCmd, { stdio: 'pipe', encoding: 'utf-8' }) + s.stop('Custom Drizzle migration generated.') + } catch (error) { + s.stop('Failed to generate migration.') + const stderr = + error !== null && + typeof error === 'object' && + 'stderr' in error && + typeof error.stderr === 'string' + ? error.stderr.trim() + : undefined + p.log.error( + stderr || (error instanceof Error ? error.message : 'Unknown error.'), + ) + p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit') + p.outro('Migration aborted.') + process.exit(1) + } + + // Step 2 — locate the file drizzle-kit just wrote. + let migrationPath: string + s.start('Locating generated migration file...') + try { + migrationPath = await findGeneratedMigration(outDir, migrationName) + s.stop(`Found migration: ${migrationPath}`) + } catch (error) { + s.stop('Failed to locate migration file.') + p.log.error(error instanceof Error ? error.message : String(error)) + p.outro('Migration aborted.') + process.exit(1) + } + + // Step 3 — write the EQL v3 install SQL into it. + s.start('Writing EQL v3 install SQL into migration file...') + try { + writeFileSync(migrationPath, sql, 'utf-8') + s.stop('EQL v3 install SQL written.') + } catch (error) { + s.stop('Failed to write migration file.') + p.log.error(error instanceof Error ? error.message : String(error)) + cleanupMigrationFile(migrationPath) + p.outro('Migration aborted.') + process.exit(1) + } + + p.log.success(`Migration created: ${migrationPath}`) + p.note( + `Run your Drizzle migrations to install EQL v3:\n\n ${runner} drizzle-kit migrate`, + 'Next Steps', + ) + p.outro('Done!') +} diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 90bc52bb..c652e559 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -66,6 +66,20 @@ export const messages = { * actionable command + `--force` note are appended at the call site. */ prismaNextDetected: 'This looks like a Prisma Next project', + /** `stash eql migration` with no `--drizzle`/`--prisma` target. */ + migrationNeedsTarget: + 'Specify a target: `stash eql migration --drizzle` (or `--prisma`).', + /** More than one target passed to `stash eql migration`. */ + migrationOneTarget: + 'Pass exactly one target: `--drizzle` or `--prisma`, not both.', + /** + * `--prisma` isn't wired yet — Prisma Next installs EQL v3 through its own + * migration system (`prisma-next migration apply`), so the CLI emitter is + * not needed there today. Points at the tracking issue so the failure is + * actionable. + */ + migrationPrismaUnavailable: + '`stash eql migration --prisma` is not available yet — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`, see cipherstash/stack#690). Use `--drizzle` today.', }, db: { unknownSubcommand: 'Unknown db subcommand', diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index a075af6e..fdebb474 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -334,6 +334,7 @@ Flags below are the decision-relevant ones. Run `stash --help` for the ```bash stash eql install +stash eql migration --drizzle stash eql upgrade stash eql status ``` @@ -359,7 +360,7 @@ Gets a project from zero to installed EQL. It loads an existing `stash.config.ts `--migration`, `--direct`, and `--migrations-dir` require an explicit `--supabase`; they never auto-enable it. -**EQL v3 installs via the direct path only.** Passing `--eql-version 3` with an explicit `--drizzle`, `--migration`, `--migrations-dir`, or `--latest` is an error. When Drizzle or a Supabase migrations directory is merely *auto-detected*, v3 falls back to a direct install and prints a notice. +**`eql install` for EQL v3 runs the direct path only.** Passing `--eql-version 3` with an explicit `--drizzle`, `--migration`, `--migrations-dir`, or `--latest` is an error. When Drizzle or a Supabase migrations directory is merely *auto-detected*, v3 falls back to a direct install and prints a notice. To get a **v3 install as a migration** (preferred for real projects), use `eql migration` (below) instead of `eql install --drizzle`. **`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx stash eql install --database-url postgres://...` run in a bare project with no CipherStash dependencies. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database. @@ -367,6 +368,26 @@ Gets a project from zero to installed EQL. It loads an existing `stash.config.ts Direct installs (`--supabase --direct`) do **not** survive `supabase db reset` — the reset drops the database and replays only files in `supabase/migrations/`. Use `--migration` if you reset. +#### `eql migration` + +Generates an **EQL v3 install migration** for your ORM, instead of running SQL directly against the database (`eql install`). Migration-first is the preferred path: the install lands in your migration history and ships to every environment through the ORM's own migrate step. v3 only — there is no `--eql-version` here. + +```bash +stash eql migration --drizzle # Drizzle custom migration in drizzle/ +stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authenticated/service_role +``` + +| Flag | Description | +|---|---| +| `--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** — ships with prisma-next EQL v3 support; fails with a pointer until then. | +| `--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`. | +| `--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. + #### `eql upgrade` The install SQL is idempotent. `upgrade` checks the current version, re-runs it, and reports the new one. If EQL isn't installed it points you at `eql install`. Same `--supabase`, `--exclude-operator-family`, `--eql-version`, `--latest`, `--dry-run`, `--database-url` flags. diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 79aa07cf..3c2a6ebb 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -44,13 +44,22 @@ It is distinct from the older, separate `@cipherstash/drizzle` package (which is ### Install the EQL v3 SQL -EQL (Encrypt Query Language) provides the PostgreSQL functions and domains that make encrypted columns searchable. Install version 3 directly against the database: +EQL (Encrypt Query Language) provides the PostgreSQL functions and domains that make encrypted columns searchable. Two ways to install version 3: + +**Direct install** — run the SQL straight against the database (quick, good for dev): ```bash stash eql install --eql-version 3 ``` -v3 installs via the direct path only — the v2 `stash eql install --drizzle` Drizzle-migration flow is **not** supported for v3 (`--drizzle`, `--migration`, `--migrations-dir`, and `--latest` are v2-only flags). EQL v3 ships one SQL bundle for every target, including Supabase. +**Migration (preferred for real projects)** — generate a Drizzle custom migration that carries the EQL v3 install SQL, so it lands in your migration history and ships to every environment through `drizzle-kit migrate`: + +```bash +stash eql migration --drizzle # writes a custom migration into drizzle/ +stash eql migration --drizzle --supabase # also grants eql_v3 to anon/authenticated/service_role +``` + +The generated migration also installs the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers everything `stash encrypt …` needs — no out-of-band `stash eql install`. EQL v3 ships one SQL bundle for every target including Supabase; `--supabase` only adds the PostgREST/RLS role grants (harmless when you connect directly as `postgres`). Requires `drizzle-kit` installed and configured. ### Column Storage From 201f7d8bc8ba6bde4692f3f9976ea61a2ec3b987 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 23:17:58 +1000 Subject: [PATCH 2/5] fix(cli): harden `stash eql migration` per review (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/cli/src/bin/main.ts | 1 + packages/cli/src/cli/registry.ts | 4 +- .../find-generated-migration.test.ts | 49 ++++ packages/cli/src/commands/db/install.ts | 2 +- .../commands/eql/__tests__/migration.test.ts | 224 ++++++++++++++++-- packages/cli/src/commands/eql/migration.ts | 105 +++++--- .../cli/src/commands/init/lib/setup-prompt.ts | 23 +- packages/cli/src/commands/init/utils.ts | 46 ++++ packages/cli/src/messages.ts | 3 + .../cli/tests/e2e/command-help.e2e.test.ts | 11 + packages/cli/tests/e2e/smoke.e2e.test.ts | 1 + 11 files changed, 401 insertions(+), 68 deletions(-) create mode 100644 packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 7d9af38c..1b164fa5 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -108,6 +108,7 @@ Commands: telemetry Manage anonymous usage analytics (status, enable, disable) eql install Scaffold stash.config.ts (if missing) and install EQL extensions + eql migration Generate an EQL v3 install migration for your ORM (Drizzle) eql upgrade Upgrade EQL extensions to the latest version eql status Show EQL installation status diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 67680a37..ad667e39 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -405,13 +405,13 @@ export const registry: CommandGroup[] = [ name: '--name', value: '', description: - 'Name for the generated migration (Drizzle). Defaults to `install-eql`.', + 'Name for the generated migration (Drizzle). Letters, numbers, dashes, underscores only. Defaults to `install-eql`.', }, { name: '--out', value: '', description: - 'Directory to write the generated migration into (Drizzle). Defaults to `drizzle`.', + 'Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts.', }, DRY_RUN_FLAG, ], diff --git a/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts b/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts new file mode 100644 index 00000000..8674bdf9 --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { findGeneratedMigration } from '../install.js' + +/** + * `findGeneratedMigration` was promoted to public API by the `eql migration` + * command and now has two consumers (`db install --drizzle` and + * `eql migration --drizzle`), so its branches are pinned directly — a change for + * one consumer must not silently break the other. + */ +describe('findGeneratedMigration', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'stash-find-migration-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('throws when the out directory does not exist', async () => { + await expect( + findGeneratedMigration(join(dir, 'nope'), 'install-eql'), + ).rejects.toThrow(/output directory not found/) + }) + + it('throws when no .sql file matches the migration name', async () => { + writeFileSync(join(dir, '0000_other.sql'), '') + await expect(findGeneratedMigration(dir, 'install-eql')).rejects.toThrow( + /Could not find a migration matching "install-eql"/, + ) + }) + + it('returns the highest-numbered match, ignoring non-.sql and non-matching entries', async () => { + for (const f of [ + '0000_install-eql.sql', + '0010_install-eql.sql', + '0011_install-eql.txt', // not .sql + '0001_users.sql', // doesn't match the name + ]) { + writeFileSync(join(dir, f), '') + } + // Relies on drizzle-kit's zero-padded 4-digit prefix for lexical == numeric. + expect(await findGeneratedMigration(dir, 'install-eql')).toBe( + join(dir, '0010_install-eql.sql'), + ) + }) +}) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 1127bcf2..4c2a01eb 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -413,7 +413,7 @@ function resolveProviderOptions( return { supabase, drizzle, excludeOperatorFamily } } -function printNextSteps(): void { +export function printNextSteps(): void { p.note( [ 'Your project is set up. To encrypt your first column, pick the path', diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index 37cfa2ab..5225305d 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -1,20 +1,79 @@ -import { describe, expect, it } from 'vitest' -import { buildEqlV3MigrationSql } from '../migration.js' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +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 { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js' + +// clack is chrome — silence it and spy on the error/note channels the command +// reports through. +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, +})) + +// Stub the drizzle-kit scaffold — only the child process is faked. +const spawnMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ spawnSync: spawnMock })) + +// `node:fs` stays REAL by default (so `loadBundledEqlSql` reads the bundled SQL +// and the tmpdir writes/reads work) — only `writeFileSync` is a spy that +// delegates to the real impl, so the cleanup test can make just the SQL write +// throw without touching everything else. `beforeEach` restores the delegating +// default after `clearAllMocks`. +const fsWrite = vi.hoisted(() => ({ + real: undefined as unknown 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 } +}) + +// `printNextSteps` lives in the install module, which drags in `pg`. Stub it; +// the two helpers we reuse (`findGeneratedMigration`, `cleanupMigrationFile`) +// stay real and act on the tmpdir. +vi.mock('../../db/install.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, printNextSteps: vi.fn() } +}) + +beforeEach(() => { + fsWrite.spy.mockImplementation(fsWrite.real) +}) +afterEach(() => { + vi.clearAllMocks() +}) /** - * `buildEqlV3MigrationSql` is the pure core of `stash eql migration --drizzle`: - * it assembles the migration contents from the CLI's bundled v3 install SQL, - * the optional Supabase grants, and the `cs_migrations` tracking schema. The - * file-writing orchestration around it (drizzle-kit scaffold + inject) is thin - * and I/O-bound, so the assembly is where the contract lives. + * `buildEqlV3MigrationSql` is the pure core: it assembles the migration from the + * CLI's bundled v3 install SQL, the optional Supabase grants, and the + * `cs_migrations` tracking schema. */ describe('buildEqlV3MigrationSql', () => { it('emits the EQL v3 install bundle and the cs_migrations tracking schema', () => { const sql = buildEqlV3MigrationSql({ supabase: false }) - // v3 bundle, not v2 — the whole point of the command. expect(sql).toContain('EQL v3 schema creation') expect(sql).toContain('eql_v3') - // Bundled tracking schema so one migration run is enough for `stash encrypt`. expect(sql).toContain('cs_migrations') }) @@ -35,12 +94,145 @@ describe('buildEqlV3MigrationSql', () => { ) }) - it('is a superset of the non-supabase content when --supabase is set', () => { - const base = buildEqlV3MigrationSql({ supabase: false }) - const supa = buildEqlV3MigrationSql({ supabase: true }) - // The grants are additive: everything in the base still appears. - expect(supa.length).toBeGreaterThan(base.length) - expect(supa).toContain('EQL v3 schema creation') - expect(supa).toContain('cs_migrations') + it('orders the bundle: schema creation → grants → tracking schema', () => { + // Order is the contract: `GRANT ... ON SCHEMA eql_v3` against a not-yet- + // created schema is a hard error at migrate time, so `toContain` isn't + // enough — the offsets must be monotonic. + const sql = buildEqlV3MigrationSql({ supabase: true }) + const schemaAt = sql.indexOf('EQL v3 schema creation') + const grantAt = sql.indexOf('GRANT USAGE ON SCHEMA eql_v3 TO') + const trackingAt = sql.indexOf( + '-- CipherStash encryption-migration tracking schema.', + ) + expect(schemaAt).toBeGreaterThanOrEqual(0) + expect(grantAt).toBeGreaterThan(schemaAt) + expect(trackingAt).toBeGreaterThan(grantAt) + }) +}) + +describe('eqlMigrationCommand — target selection', () => { + it.each([ + ['no target', {}, () => messages.eql.migrationNeedsTarget], + [ + 'both targets', + { drizzle: true, prisma: true }, + () => messages.eql.migrationOneTarget, + ], + [ + '--prisma', + { prisma: true }, + () => messages.eql.migrationPrismaUnavailable, + ], + // `--supabase` is a modifier, not a target. + [ + '--supabase alone', + { supabase: true }, + () => messages.eql.migrationNeedsTarget, + ], + ])('exits 1 with an actionable message for %s', async (_label, opts, msg) => { + await expect(eqlMigrationCommand(opts)).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith(msg()) + expect(spawnMock).not.toHaveBeenCalled() + }) +}) + +describe('eqlMigrationCommand — Drizzle', () => { + let tmp: string + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'stash-eql-migration-')) + }) + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('rejects a migration name with unsafe characters before spawning', async () => { + await expect( + eqlMigrationCommand({ drizzle: true, name: 'a b; rm -rf /' }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith(messages.eql.migrationBadName) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('dry run neither spawns drizzle-kit nor creates the out directory', async () => { + const out = join(tmp, 'drizzle') + await eqlMigrationCommand({ drizzle: true, dryRun: true, out }) + expect(spawnMock).not.toHaveBeenCalled() + expect(existsSync(out)).toBe(false) + expect(clack.note).toHaveBeenCalledWith( + expect.stringContaining( + 'drizzle-kit generate --custom --name=install-eql', + ), + 'Dry Run', + ) + }) + + it('dry run says the grants would be included under --supabase', async () => { + await eqlMigrationCommand({ + drizzle: true, + dryRun: true, + supabase: true, + out: join(tmp, 'd'), + }) + expect(clack.note).toHaveBeenCalledWith( + expect.stringContaining('(with Supabase grants)'), + 'Dry Run', + ) + }) + + it('threads --name/--out into 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 eqlMigrationCommand({ drizzle: true, name: 'add-eql', out }) + + // argv array, never a shell string — the name/out are discrete tokens. + const [, argv] = spawnMock.mock.calls[0] + expect(argv).toContain('drizzle-kit') + expect(argv).toContain('--name=add-eql') + expect(argv).toContain(`--out=${out}`) + + const written = readFileSync(join(out, '0000_add-eql.sql'), 'utf-8') + expect(written).toContain('EQL v3 schema creation') + expect(written).toContain('cs_migrations') + }) + + it('aborts (exit 1) when drizzle-kit exits non-zero', async () => { + spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' }) + await expect( + eqlMigrationCommand({ drizzle: true, out: join(tmp, 'drizzle') }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.error).toHaveBeenCalledWith('boom') + }) + + 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') + spawnMock.mockImplementation(() => { + // drizzle-kit's empty custom migration (delegates to real write). + fsWrite.real(scaffolded, '') + return { status: 0, stdout: '', stderr: '' } + }) + // Throw on the SQL write specifically (the big bundle), letting the empty + // scaffold write through — robust to call order. + fsWrite.spy.mockImplementation(((path: string, data: unknown, ...rest) => { + if (typeof data === 'string' && data.includes('EQL v3 schema creation')) { + throw new Error('EACCES: permission denied') + } + return fsWrite.real(path, data as string, ...(rest as [])) + }) as typeof import('node:fs').writeFileSync) + + await expect( + eqlMigrationCommand({ drizzle: true, out }), + ).rejects.toBeInstanceOf(CliExit) + + // The empty scaffold must not survive — drizzle-kit would happily run it. + expect(existsSync(scaffolded)).toBe(false) + expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied') }) }) diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 404780fc..96153ce4 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -1,19 +1,28 @@ -import { execSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { MIGRATIONS_SCHEMA_SQL } from '@cipherstash/migrate' import * as p from '@clack/prompts' +import { CliExit } from '@/cli/exit.js' import { cleanupMigrationFile, findGeneratedMigration, + printNextSteps, } from '@/commands/db/install.js' -import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { + detectPackageManager, + execArgv, + execCommand, +} from '@/commands/init/utils.js' import { loadBundledEqlSql, supabaseGrantsFor } from '@/installer/index.js' 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 @@ -41,7 +50,8 @@ export interface EqlMigrationOptions { * idempotent and harmless, and required when the same tables are reached via * PostgREST/RLS. * - * The `cs_migrations` tracking schema is bundled in so a single migration run + * Order is load-bearing: schema creation → grants (which reference that schema) + * → the `cs_migrations` tracking schema, appended last so one migration run * installs everything `stash encrypt …` needs — no out-of-band `stash eql * install`. */ @@ -62,6 +72,10 @@ export function buildEqlV3MigrationSql(opts: { supabase: boolean }): string { * * v3 only — there is no `--eql-version` here. prisma-next never shipped v2, and * the Drizzle v3 surface is the documented one. + * + * Validation exits throw {@link CliExit} rather than `process.exit` so the + * telemetry `finally` in `main.ts` still runs (and the branches stay unit + * testable). */ export async function eqlMigrationCommand( options: EqlMigrationOptions, @@ -72,11 +86,11 @@ export async function eqlMigrationCommand( ].filter(Boolean) if (targets.length === 0) { p.log.error(messages.eql.migrationNeedsTarget) - process.exit(1) + throw new CliExit(1) } if (targets.length > 1) { p.log.error(messages.eql.migrationOneTarget) - process.exit(1) + throw new CliExit(1) } if (options.prisma) { @@ -84,7 +98,7 @@ export async function eqlMigrationCommand( // (PR #655); it can't install a v3 schema that doesn't exist on that // surface yet. Fail loudly with a pointer rather than emit a broken file. p.log.error(messages.eql.migrationPrismaUnavailable) - process.exit(1) + throw new CliExit(1) } await generateDrizzleEqlMigration(options) @@ -94,15 +108,48 @@ async function generateDrizzleEqlMigration( options: EqlMigrationOptions, ): Promise { const migrationName = options.name ?? DEFAULT_MIGRATION_NAME + if (!SAFE_MIGRATION_NAME.test(migrationName)) { + p.log.error(messages.eql.migrationBadName) + throw new CliExit(1) + } const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) - const runner = runnerCommand(detectPackageManager(), '').trim() - const drizzleCmd = `${runner} drizzle-kit generate --custom --name=${migrationName}` + const pm = detectPackageManager() + + // Run the PROJECT-LOCAL drizzle-kit (`pnpm exec` / `npx --no-install`), not the + // download-and-run form — it must resolve this project's drizzle.config.ts and + // schema. 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. + // It defaults to `drizzle/`; override with `--out` to match your config. + const { command, prefixArgs } = execArgv(pm) + const drizzleArgs = [ + ...prefixArgs, + 'drizzle-kit', + 'generate', + '--custom', + `--name=${migrationName}`, + `--out=${outDir}`, + ] + const displayCmd = `${execCommand(pm)} ${drizzleArgs.slice(prefixArgs.length).join(' ')}` + + // Load the SQL up front so a corrupt/missing bundle fails BEFORE we scaffold + // anything (nothing to orphan), with the same spinner-free error the other + // steps use rather than a raw fatal stack trace. + let sql: string + try { + sql = buildEqlV3MigrationSql({ supabase: options.supabase ?? false }) + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)) + throw new CliExit(1) + } - const sql = buildEqlV3MigrationSql({ supabase: options.supabase ?? false }) + p.intro('CipherStash EQL migration') if (options.dryRun) { p.note( - `Would run: ${drizzleCmd}\nWould write the EQL v3 install SQL${options.supabase ? ' (with Supabase grants)' : ''} into the generated migration in ${outDir}`, + `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.') @@ -114,25 +161,25 @@ async function generateDrizzleEqlMigration( // Step 1 — scaffold an empty custom migration (drizzle-kit owns the journal // + sequence numbering; hand-rolling that is fragile). 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 + const stderr = result.stderr?.trim() p.log.error( - stderr || (error instanceof Error ? error.message : 'Unknown error.'), + stderr || + result.error?.message || + `drizzle-kit exited with status ${result.status ?? 'unknown'}.`, + ) + p.log.info( + `Make sure drizzle-kit is installed and configured: ${execCommand(pm)} drizzle-kit --version`, ) - 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 — locate the file drizzle-kit just wrote. let migrationPath: string @@ -143,8 +190,11 @@ async function generateDrizzleEqlMigration( } 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 — write the EQL v3 install SQL into it. @@ -157,13 +207,14 @@ async function generateDrizzleEqlMigration( p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(migrationPath) p.outro('Migration aborted.') - process.exit(1) + throw new CliExit(1) } p.log.success(`Migration created: ${migrationPath}`) p.note( - `Run your Drizzle migrations to install EQL v3:\n\n ${runner} drizzle-kit migrate`, + `Run your Drizzle migrations to install EQL v3:\n\n ${execCommand(pm)} drizzle-kit migrate`, 'Next Steps', ) + printNextSteps() p.outro('Done!') } diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 550e0cff..728ea1a2 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -1,5 +1,5 @@ import type { HandoffChoice, InitMode, Integration } from '../types.js' -import { type PackageManager, runnerCommand } from '../utils.js' +import { execCommand, type PackageManager, runnerCommand } from '../utils.js' import type { PlanStep } from './parse-plan.js' export const PLAN_REL_PATH = '.cipherstash/plan.md' @@ -67,27 +67,6 @@ function migrationCommands( return undefined } -/** - * Map the package manager to the right "run a binary from node_modules" form. - * npm → `npx --no-install` (avoid surprise downloads when the dep should - * already be installed) - * pnpm → `pnpm exec` - * yarn → `yarn` (yarn 1) or `yarn run` — `yarn ` works for both - * bun → `bun x` (binary-runner mode, not the dlx alias) - */ -function execCommand(pm: PackageManager): string { - switch (pm) { - case 'npm': - return 'npx --no-install' - case 'pnpm': - return 'pnpm exec' - case 'yarn': - return 'yarn' - case 'bun': - return 'bun x' - } -} - function bullet(line: string): string { return `- ${line}` } diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 98e6d932..276378a5 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -213,6 +213,52 @@ export function runnerArgv(pm: PackageManager): { } } +/** + * Map the package manager to the right "run a binary from node_modules" form — + * distinct from {@link runnerCommand}, which is the download-and-run + * (`pnpm dlx` / `npx `) form. For a project-local binary like drizzle-kit + * you want the local one, so it resolves the project's own install and its + * `drizzle.config.ts` / schema imports rather than fetching a fresh copy into a + * temp store. + * npm → `npx --no-install` (never surprise-download a dep that should exist) + * pnpm → `pnpm exec` + * yarn → `yarn` (works for yarn 1 and berry: `yarn `) + * bun → `bun x` (binary-runner mode, not the `bunx` dlx alias) + */ +export function execCommand(pm: PackageManager): string { + switch (pm) { + case 'npm': + return 'npx --no-install' + case 'pnpm': + return 'pnpm exec' + case 'yarn': + return 'yarn' + case 'bun': + return 'bun x' + } +} + +/** + * argv split of {@link execCommand} for callers using `spawnSync` / `spawn` + * (no shell → no word-splitting or injection from interpolated arguments). + * Returns the binary plus any prefix args; the caller appends the rest. + */ +export function execArgv(pm: PackageManager): { + command: string + prefixArgs: string[] +} { + switch (pm) { + case 'npm': + return { command: 'npx', prefixArgs: ['--no-install'] } + case 'pnpm': + return { command: 'pnpm', prefixArgs: ['exec'] } + case 'yarn': + return { command: 'yarn', prefixArgs: [] } + case 'bun': + return { command: 'bun', prefixArgs: ['x'] } + } +} + function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index c652e559..e0679daf 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -80,6 +80,9 @@ export const messages = { */ migrationPrismaUnavailable: '`stash eql migration --prisma` is not available yet — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`, see cipherstash/stack#690). Use `--drizzle` today.', + /** `--name` carried characters outside `[A-Za-z0-9_-]`. */ + migrationBadName: + 'Migration name must contain only letters, numbers, dashes, and underscores.', }, db: { unknownSubcommand: 'Unknown db subcommand', diff --git a/packages/cli/tests/e2e/command-help.e2e.test.ts b/packages/cli/tests/e2e/command-help.e2e.test.ts index b8e27777..0134ebec 100644 --- a/packages/cli/tests/e2e/command-help.e2e.test.ts +++ b/packages/cli/tests/e2e/command-help.e2e.test.ts @@ -17,12 +17,23 @@ describe('per-command --help', () => { expect(r.exitCode).toBe(0) expect(r.output).toContain('Usage: npx stash eql [options]') expect(r.output).toContain('eql install') + expect(r.output).toContain('eql migration') expect(r.output).toContain('eql upgrade') expect(r.output).toContain('eql status') // A group listing must NOT be the global banner. expect(r.output).not.toContain('CipherStash CLI v') }) + it('renders full command help for `eql migration --help`', async () => { + const r = await run(['eql', 'migration', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash eql migration [options]') + expect(r.output).toContain('--drizzle') + expect(r.output).toContain('--supabase') + }) + it('renders full command help for `eql install --help`', async () => { const r = await run(['eql', 'install', '--help'], { env: { npm_config_user_agent: '' }, diff --git a/packages/cli/tests/e2e/smoke.e2e.test.ts b/packages/cli/tests/e2e/smoke.e2e.test.ts index f872b19e..d2eecbcc 100644 --- a/packages/cli/tests/e2e/smoke.e2e.test.ts +++ b/packages/cli/tests/e2e/smoke.e2e.test.ts @@ -23,6 +23,7 @@ describe('stash CLI — non-interactive smoke', () => { // copy strings, so they stay inline. expect(r.output).toContain('init') expect(r.output).toContain('eql install') + expect(r.output).toContain('eql migration') expect(r.output).toContain('eql upgrade') expect(r.output).toContain('eql status') // The dotenv "injected env" banner regression guard lives in the From 638117111eee3123508208acdc5ac93bd4ef8869 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 18 Jul 2026 22:41:10 +1000 Subject: [PATCH 3/5] fix(cli): bun exec must not surprise-download; drop type-erased test cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #691: - `execArgv`/`execCommand` bun case emitted `bun x`, which auto-installs a missing binary from npm — contradicting the "run a project-local binary, never surprise-download" contract these helpers exist to honour (the npm case already passes `--no-install`). Bun supports `--no-install`; pass it for both the argv and shell forms. Updated the setup-prompt assertion to match. - migration.test.ts: replaced the type-erasing `undefined as unknown as writeFileSync` hoisted-mock placeholder with a throwing placeholder cast to the specific type — fails loud if the mock factory never runs, and drops the `as unknown` (test files are plugin-exempt, but this is cleaner). CLI build + 566 unit tests green; biome clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../cli/src/commands/eql/__tests__/migration.test.ts | 9 ++++++++- .../src/commands/init/lib/__tests__/setup-prompt.test.ts | 2 +- packages/cli/src/commands/init/utils.ts | 7 ++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index 5225305d..5ca7b2a7 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -40,7 +40,14 @@ vi.mock('node:child_process', () => ({ spawnSync: spawnMock })) // throw without touching everything else. `beforeEach` restores the delegating // default after `clearAllMocks`. const fsWrite = vi.hoisted(() => ({ - real: undefined as unknown as typeof import('node:fs').writeFileSync, + // Populated by the `node:fs` mock factory below (which always runs before any + // test). The placeholder throws rather than being a type-erased `undefined`, + // so a missed initialisation fails loudly instead of calling `undefined()`. + real: (() => { + throw new Error( + 'fsWrite.real not initialised: node:fs mock factory did not run', + ) + }) as typeof import('node:fs').writeFileSync, spy: vi.fn(), })) vi.mock('node:fs', async (importOriginal) => { diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index 9211804e..05e47a28 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -110,7 +110,7 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => { const yarn = renderSetupPrompt({ ...baseCtx, packageManager: 'yarn' }) expect(npm).toContain('npx --no-install drizzle-kit generate') - expect(bun).toContain('bun x drizzle-kit generate') + expect(bun).toContain('bun x --no-install drizzle-kit generate') expect(yarn).toContain('yarn drizzle-kit generate') }) diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 276378a5..19e551ae 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -223,7 +223,8 @@ export function runnerArgv(pm: PackageManager): { * npm → `npx --no-install` (never surprise-download a dep that should exist) * pnpm → `pnpm exec` * yarn → `yarn` (works for yarn 1 and berry: `yarn `) - * bun → `bun x` (binary-runner mode, not the `bunx` dlx alias) + * bun → `bun x --no-install` (binary-runner mode, not the `bunx` dlx alias; + * `--no-install` keeps it from auto-fetching a missing binary from npm) */ export function execCommand(pm: PackageManager): string { switch (pm) { @@ -234,7 +235,7 @@ export function execCommand(pm: PackageManager): string { case 'yarn': return 'yarn' case 'bun': - return 'bun x' + return 'bun x --no-install' } } @@ -255,7 +256,7 @@ export function execArgv(pm: PackageManager): { case 'yarn': return { command: 'yarn', prefixArgs: [] } case 'bun': - return { command: 'bun', prefixArgs: ['x'] } + return { command: 'bun', prefixArgs: ['x', '--no-install'] } } } From adf5243450ce7ae603d29699cb713ef221e3a449 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 18 Jul 2026 23:02:15 +1000 Subject: [PATCH 4/5] docs(cli): correct stale `--prisma` copy now that prisma-next v3 has shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dan's review on #691: the `stash eql migration --prisma` copy said the flag "ships with prisma-next EQL v3 support" / "not available yet" — but that support has shipped (#655/#683/#685), and Prisma Next installs EQL v3 through its OWN migration system (`prisma-next migration apply`), not through this command. Reframed all five spots to current reality (not "coming soon", but "use prisma-next migration apply"): the user-facing message + its doc comment, the `eqlMigrationCommand` code comment, the `--prisma` flag help in the registry, the changeset, and the stash-cli skill (which ships to customers). No behaviour change — `--prisma` still fails with a pointer. Build + 566 tests green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-migration-command.md | 5 +++-- packages/cli/src/cli/registry.ts | 2 +- packages/cli/src/commands/eql/migration.ts | 6 +++--- packages/cli/src/messages.ts | 10 +++++----- skills/stash-cli/SKILL.md | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.changeset/eql-migration-command.md b/.changeset/eql-migration-command.md index 09ff25e2..7f526e4e 100644 --- a/.changeset/eql-migration-command.md +++ b/.changeset/eql-migration-command.md @@ -17,5 +17,6 @@ the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers everything `stash encrypt …` needs. `--supabase` appends the `eql_v3` + `eql_v3_internal` role grants for PostgREST/RLS access. -`--prisma` is registered but not available yet — it ships with prisma-next EQL v3 -support and fails with a pointer until then. +`--prisma` is registered but not supported — Prisma Next installs EQL v3 through +its own migration system (`prisma-next migration apply`), so the command points +users there instead of emitting a migration. diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index ad667e39..6bf8c33a 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -394,7 +394,7 @@ export const registry: CommandGroup[] = [ { name: '--prisma', description: - 'Emit a Prisma Next migration (ships with prisma-next EQL v3 support; not available yet).', + 'Not supported — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`); fails with a pointer.', }, { name: '--supabase', diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 96153ce4..4336ae8b 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -94,9 +94,9 @@ export async function eqlMigrationCommand( } if (options.prisma) { - // The prisma-next emitter ships stacked on the prisma-next EQL v3 work - // (PR #655); it can't install a v3 schema that doesn't exist on that - // surface yet. Fail loudly with a pointer rather than emit a broken file. + // Prisma Next installs EQL v3 through its own migration system + // (`prisma-next migration apply`, shipped in #655), so this command has + // nothing to emit for it. Fail loudly with a pointer rather than a no-op. p.log.error(messages.eql.migrationPrismaUnavailable) throw new CliExit(1) } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index e0679daf..8d657a6c 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -73,13 +73,13 @@ export const messages = { migrationOneTarget: 'Pass exactly one target: `--drizzle` or `--prisma`, not both.', /** - * `--prisma` isn't wired yet — Prisma Next installs EQL v3 through its own - * migration system (`prisma-next migration apply`), so the CLI emitter is - * not needed there today. Points at the tracking issue so the failure is - * actionable. + * `--prisma` is registered but has nothing to emit: Prisma Next installs + * EQL v3 through its own migration system (`prisma-next migration apply`), + * so this command doesn't generate a Prisma migration. The message points + * users at the path that does the work. */ migrationPrismaUnavailable: - '`stash eql migration --prisma` is not available yet — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`, see cipherstash/stack#690). Use `--drizzle` today.', + '`stash eql migration --prisma` is not supported — Prisma Next installs EQL v3 through its own migration system. Run `prisma-next migration apply` instead. Use `--drizzle` for a Drizzle project.', /** `--name` carried characters outside `[A-Za-z0-9_-]`. */ migrationBadName: 'Migration name must contain only letters, numbers, dashes, and underscores.', diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index fdebb474..c46f9819 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -380,7 +380,7 @@ stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authentic | Flag | Description | |---|---| | `--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** — ships with prisma-next EQL v3 support; fails with a pointer until then. | +| `--prisma` | **Not supported.** Prisma Next installs EQL v3 through its own migration system — run `prisma-next migration apply` instead. The flag fails with a pointer. | | `--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`. | From d1b306ff348e28005c10aa7748f7fcc0ef1bd79f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 18 Jul 2026 23:10:22 +1000 Subject: [PATCH 5/5] docs(cli): correct `--prisma` copy to match #690 (planned emitter, not "use prisma-next") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My previous pass framed `--prisma` as "not supported — use `prisma-next migration apply`", which contradicts #690: the `--prisma` emitter IS a planned follow-up (PR3) that writes the install migration in the framework `Migration` shape and lets prisma-next DROP its baked install baseline. Pointing users at `prisma-next migration apply` is the very approach #690 replaces. Reframe all five spots to "not available yet — the Prisma Next emitter is a follow-up tracked in #690; use `--drizzle` today": the message + its doc comment, the `eqlMigrationCommand` code comment, the `--prisma` flag help, the changeset, and the stash-cli skill. (The `stash eql install` guard + stash-prisma-next skill keep their `prisma-next migration apply` wording — that correctly describes the current baked-baseline install, which is a different thing from this CLI emitter.) No behaviour change. Build + 566 tests green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-migration-command.md | 6 +++--- packages/cli/src/cli/registry.ts | 2 +- packages/cli/src/commands/eql/migration.ts | 7 ++++--- packages/cli/src/messages.ts | 10 +++++----- skills/stash-cli/SKILL.md | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.changeset/eql-migration-command.md b/.changeset/eql-migration-command.md index 7f526e4e..fc7fc41e 100644 --- a/.changeset/eql-migration-command.md +++ b/.changeset/eql-migration-command.md @@ -17,6 +17,6 @@ the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers everything `stash encrypt …` needs. `--supabase` appends the `eql_v3` + `eql_v3_internal` role grants for PostgREST/RLS access. -`--prisma` is registered but not supported — Prisma Next installs EQL v3 through -its own migration system (`prisma-next migration apply`), so the command points -users there instead of emitting a migration. +`--prisma` is registered but not available yet — the Prisma Next migration +emitter is a follow-up (tracked in cipherstash/stack#690) that will let +prisma-next drop its baked install baseline. It fails with a pointer for now. diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 6bf8c33a..5234c82f 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -394,7 +394,7 @@ export const registry: CommandGroup[] = [ { name: '--prisma', description: - 'Not supported — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`); fails with a pointer.', + 'Emit a Prisma Next migration — not available yet; the emitter is a follow-up tracked in cipherstash/stack#690.', }, { name: '--supabase', diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 4336ae8b..19223ec2 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -94,9 +94,10 @@ export async function eqlMigrationCommand( } if (options.prisma) { - // Prisma Next installs EQL v3 through its own migration system - // (`prisma-next migration apply`, shipped in #655), so this command has - // nothing to emit for it. Fail loudly with a pointer rather than a no-op. + // The Prisma Next emitter is a follow-up (tracked in #690): it will write + // the install migration in the framework `Migration` shape and let + // prisma-next drop its baked install baseline. Until it lands, fail loudly + // with a pointer rather than emit a broken/empty file. p.log.error(messages.eql.migrationPrismaUnavailable) throw new CliExit(1) } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 8d657a6c..e80919fa 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -73,13 +73,13 @@ export const messages = { migrationOneTarget: 'Pass exactly one target: `--drizzle` or `--prisma`, not both.', /** - * `--prisma` is registered but has nothing to emit: Prisma Next installs - * EQL v3 through its own migration system (`prisma-next migration apply`), - * so this command doesn't generate a Prisma migration. The message points - * users at the path that does the work. + * `--prisma` is registered but its Prisma Next emitter isn't built yet — + * it's a follow-up (tracked in #690) that will emit the install migration + * in the framework `Migration` shape and let prisma-next drop its baked + * install baseline. Fail with a pointer rather than a silent no-op. */ migrationPrismaUnavailable: - '`stash eql migration --prisma` is not supported — Prisma Next installs EQL v3 through its own migration system. Run `prisma-next migration apply` instead. Use `--drizzle` for a Drizzle project.', + '`stash eql migration --prisma` is not available yet — the Prisma Next emitter is a follow-up (tracked in cipherstash/stack#690). Use `--drizzle` today.', /** `--name` carried characters outside `[A-Za-z0-9_-]`. */ migrationBadName: 'Migration name must contain only letters, numbers, dashes, and underscores.', diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index c46f9819..088779ad 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -380,7 +380,7 @@ stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authentic | Flag | Description | |---|---| | `--drizzle` | Emit a Drizzle custom migration (via `drizzle-kit generate --custom`, then inject the SQL). Requires `drizzle-kit`. | -| `--prisma` | **Not supported.** Prisma Next installs EQL v3 through its own migration system — run `prisma-next migration apply` instead. The flag fails with a pointer. | +| `--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`. |