diff --git a/.changeset/eql-migration-command.md b/.changeset/eql-migration-command.md new file mode 100644 index 00000000..fc7fc41e --- /dev/null +++ b/.changeset/eql-migration-command.md @@ -0,0 +1,22 @@ +--- +'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 — 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/bin/main.ts b/packages/cli/src/bin/main.ts index b7703df7..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 @@ -232,6 +233,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..5234c82f 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 — not available yet; the emitter is a follow-up tracked in cipherstash/stack#690.', + }, + { + 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). Letters, numbers, dashes, underscores only. Defaults to `install-eql`.', + }, + { + name: '--out', + value: '', + description: + '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, + ], + }, { name: 'eql upgrade', summary: 'Upgrade EQL extensions to the latest version', 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 7ce8f6aa..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', @@ -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..5ca7b2a7 --- /dev/null +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -0,0 +1,245 @@ +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(() => ({ + // 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) => { + 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: 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 }) + expect(sql).toContain('EQL v3 schema creation') + expect(sql).toContain('eql_v3') + 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('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 new file mode 100644 index 00000000..19223ec2 --- /dev/null +++ b/packages/cli/src/commands/eql/migration.ts @@ -0,0 +1,221 @@ +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, + 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 + /** 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. + * + * 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`. + */ +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. + * + * 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, +): Promise { + const targets = [ + options.drizzle && 'drizzle', + options.prisma && 'prisma', + ].filter(Boolean) + if (targets.length === 0) { + p.log.error(messages.eql.migrationNeedsTarget) + throw new CliExit(1) + } + if (targets.length > 1) { + p.log.error(messages.eql.migrationOneTarget) + throw new CliExit(1) + } + + if (options.prisma) { + // 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) + } + + await generateDrizzleEqlMigration(options) +} + +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 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) + } + + p.intro('CipherStash EQL migration') + + if (options.dryRun) { + p.note( + `Would run: ${displayCmd}\nWould write the EQL v3 install SQL${options.supabase ? ' (with Supabase grants)' : ''} into the generated migration in ${outDir}`, + 'Dry Run', + ) + p.outro('Dry run complete.') + 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...') + const result = spawnSync(command, drizzleArgs, { + stdio: 'pipe', + encoding: 'utf-8', + }) + if (result.status !== 0) { + s.stop('Failed to generate migration.') + 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 and configured: ${execCommand(pm)} drizzle-kit --version`, + ) + p.outro('Migration aborted.') + throw new CliExit(1) + } + s.stop('Custom Drizzle migration generated.') + + // 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.log.info( + `If your drizzle.config.ts writes elsewhere, pass --out so it matches.`, + ) + p.outro('Migration aborted.') + throw new CliExit(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.') + throw new CliExit(1) + } + + p.log.success(`Migration created: ${migrationPath}`) + p.note( + `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/__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/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..19e551ae 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -213,6 +213,53 @@ 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 --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) { + case 'npm': + return 'npx --no-install' + case 'pnpm': + return 'pnpm exec' + case 'yarn': + return 'yarn' + case 'bun': + return 'bun x --no-install' + } +} + +/** + * 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', '--no-install'] } + } +} + 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 90bc52bb..e80919fa 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -66,6 +66,23 @@ 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` 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 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.', }, 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 diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index a075af6e..088779ad 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** — 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`. | +| `--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