From c2798f0dcf5127ed6be93e330bad87afd97d6eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:47:57 +0000 Subject: [PATCH 1/4] fix(cli): follow drizzle-kit's reported path when scaffolding drop migrations encrypt drop and decrypt drop scanned --migrations-dir for the file drizzle-kit wrote, ignoring the out in drizzle.config.ts. When the two differed, the SQL overwrote an older same-stem migration while drizzle-kit journaled an empty stub in the real out, so the DROP never shipped. Use the path drizzle-kit reports (shared with eql migration), fall back to an exact-suffix scan that refuses pre-existing files, and refuse to overwrite any non-empty file. Warn when --migrations-dir is overridden. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7rkoV6gTBkz2uGSR3H --- .changeset/cli-drizzle-migrations-out.md | 5 + .../packages/cli/src/cli/registry.ts | 6 +- .../find-generated-migration.test.ts | 2 +- .../cli/src/commands/db/drizzle-migration.ts | 85 +++++++++ .../encrypt/__tests__/drizzle-helper.test.ts | 166 ++++++++++++++++++ .../src/commands/encrypt/drizzle-helper.ts | 90 +++++++--- .../commands/encrypt/lib/migration-file.ts | 17 ++ .../cli/src/commands/eql/migration.ts | 69 +------- .../typescript/packages/cli/src/messages.ts | 8 +- skills/stash-cli/SKILL.md | 4 + 10 files changed, 355 insertions(+), 97 deletions(-) create mode 100644 .changeset/cli-drizzle-migrations-out.md create mode 100644 languages/typescript/packages/cli/src/commands/db/drizzle-migration.ts create mode 100644 languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts diff --git a/.changeset/cli-drizzle-migrations-out.md b/.changeset/cli-drizzle-migrations-out.md new file mode 100644 index 0000000000..923aa56457 --- /dev/null +++ b/.changeset/cli-drizzle-migrations-out.md @@ -0,0 +1,5 @@ +--- +'stash': patch +--- + +`stash encrypt drop` and `stash decrypt drop` now write the Drizzle migration to the directory drizzle-kit reports (the `out` in `drizzle.config.ts`) instead of scanning `--migrations-dir`. Previously, when the two differed, the SQL could be written over an older migration with the same name while drizzle-kit journaled an empty stub in the real `out`, so the DROP never shipped. When a scan fallback is needed it matches the exact `_.sql` suffix and refuses to overwrite any file that is not an empty drizzle-kit scaffold, and `--migrations-dir` now warns when drizzle-kit wrote elsewhere. diff --git a/languages/typescript/packages/cli/src/cli/registry.ts b/languages/typescript/packages/cli/src/cli/registry.ts index e052aa7122..26e45bdfde 100644 --- a/languages/typescript/packages/cli/src/cli/registry.ts +++ b/languages/typescript/packages/cli/src/cli/registry.ts @@ -634,7 +634,8 @@ export const registry: CommandGroup[] = [ { name: '--migrations-dir', value: '', - description: 'Directory to write the drop migration into.', + description: + 'Directory to write the drop migration into (Drizzle projects: drizzle.config.ts `out` wins; this is the fallback).', }, ], }, @@ -773,7 +774,8 @@ export const registry: CommandGroup[] = [ { name: '--migrations-dir', value: '', - description: 'Directory to write the drop migration into.', + description: + 'Directory to write the drop migration into (Drizzle projects: drizzle.config.ts `out` wins; this is the fallback).', }, { name: '--uninstall-eql', diff --git a/languages/typescript/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts b/languages/typescript/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts index f75dfc9482..7322c56f4c 100644 --- a/languages/typescript/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts +++ b/languages/typescript/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts @@ -2,7 +2,7 @@ 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 '../../eql/migration.js' +import { findGeneratedMigration } from '../drizzle-migration.js' /** * `findGeneratedMigration` locates the custom migration scaffolded by diff --git a/languages/typescript/packages/cli/src/commands/db/drizzle-migration.ts b/languages/typescript/packages/cli/src/commands/db/drizzle-migration.ts new file mode 100644 index 0000000000..48e11b7a72 --- /dev/null +++ b/languages/typescript/packages/cli/src/commands/db/drizzle-migration.ts @@ -0,0 +1,85 @@ +import { existsSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { isAbsolute, join, resolve } from 'node:path' + +/** + * Helpers for locating the file `drizzle-kit generate --custom` just wrote. + * Shared by `eql migration --drizzle` and the `encrypt drop` / `decrypt` + * scaffolds, so both agree on where drizzle-kit's `drizzle.config.ts` sent it. + */ + +/** + * Find the most recently generated Drizzle migration matching the name. + * + * Matches the exact `_.sql` suffix, so a longer name that merely contains + * the stem (`drop_users_email_backup`) is never mistaken for it. + */ +export async function findGeneratedMigration( + outDir: string, + migrationName: string, +): Promise { + if (!existsSync(outDir)) { + throw new Error( + `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`, + ) + } + const migrationSuffix = `_${migrationName}.sql` + const matchingFiles = (await readdir(outDir)) + .filter((entry) => entry.endsWith(migrationSuffix)) + .sort() + if (matchingFiles.length === 0) { + throw new Error( + `Could not find a migration matching "${migrationName}" in ${outDir}`, + ) + } + return join(outDir, matchingFiles[matchingFiles.length - 1]) +} + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC is the point +const ANSI_ESCAPE = /\u001b\[[0-9;]*[A-Za-z]/g + +function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE, '') +} + +/** + * Pull the migration path out of drizzle-kit's own success line: + * + * [✓] Your SQL migration file ➜ drizzle/0000_install-eql.sql 🚀 + * + * Since we no longer pass `--out` (drizzle-kit rejects the run when we do — + * see `generateDrizzleEqlMigration`), `drizzle.config.ts` owns the output + * directory and this line is how we learn it. The path is printed relative to + * the cwd drizzle-kit ran in, which is ours. + * + * Returns `undefined` unless the file is actually there, so an unrecognised + * banner or a moved file falls through to the directory scan rather than + * pointing the SQL write at a path that does not exist. + */ +export function parseReportedMigrationPath( + stdout: string | undefined, +): string | undefined { + if (!stdout) return undefined + // Scanned per line, taking everything between the arrow and the LAST `.sql` + // on it, because an output directory may contain spaces — a `\S+` pattern + // truncates such a path, fails the existence check, and drops us into the + // fallback scan of an unrelated directory. + // + // Last line wins: one invocation writes one file, but a wrapper that echoes + // its own banner first should not shadow drizzle-kit's. + let found: string | undefined + for (const line of stdout.split(/\r?\n/)) { + const arrow = line.indexOf('➜') + if (arrow === -1) continue + // drizzle-kit colours its banner on a TTY-like stdout; escape codes inside + // the path would fail the existence check and drop us into the fallback. + const rest = stripAnsi(line.slice(arrow + 1)) + const end = rest.lastIndexOf('.sql') + if (end === -1) continue + const candidate = rest.slice(0, end + '.sql'.length).trim() + if (!candidate) continue + const abs = isAbsolute(candidate) ? candidate : resolve(candidate) + if (existsSync(abs)) found = abs + } + return found +} diff --git a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts new file mode 100644 index 0000000000..b047c67926 --- /dev/null +++ b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts @@ -0,0 +1,166 @@ +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' + +const spawnSyncMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock })) +vi.mock('@/commands/init/utils.js', () => ({ + detectPackageManager: () => 'npm', + runnerArgv: () => ({ command: 'npx', prefixArgs: [] }), +})) + +import { scaffoldDrizzleMigration } from '../drizzle-helper.js' + +/** + * drizzle.config.ts's `out` owns where drizzle-kit writes. The scaffold must + * follow the path drizzle-kit reports, never the --migrations-dir it scans as + * a fallback (CIP-5162). + */ +describe('scaffoldDrizzleMigration', () => { + let root: string + let cwd: string + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'stash-scaffold-')) + cwd = process.cwd() + process.chdir(root) + spawnSyncMock.mockReset() + }) + afterEach(() => { + process.chdir(cwd) + rmSync(root, { recursive: true, force: true }) + }) + + function drizzleKitWrites(relPath: string) { + spawnSyncMock.mockImplementation(() => { + mkdirSync(join(relPath, '..'), { recursive: true }) + writeFileSync(relPath, '') + return { + status: 0, + stdout: `[✓] Your SQL migration file ➜ ${relPath} 🚀\n`, + stderr: '', + } + }) + } + + it('writes to the directory drizzle-kit reports, not the scanned one', async () => { + mkdirSync('drizzle') + writeFileSync('drizzle/0000_drop_users_email.sql', '-- applied earlier') + drizzleKitWrites('src/db/migrations/0003_drop_users_email.sql') + + const result = await scaffoldDrizzleMigration({ + name: 'drop_users_email', + outDir: 'drizzle', + sql: 'ALTER TABLE users DROP COLUMN email;', + }) + + expect(result.path).toBe( + join(root, 'src/db/migrations/0003_drop_users_email.sql'), + ) + expect(readFileSync(result.path, 'utf-8')).toContain('DROP COLUMN email') + expect(readFileSync('drizzle/0000_drop_users_email.sql', 'utf-8')).toBe( + '-- applied earlier', + ) + expect(result.dir).not.toBe(result.requestedDir) + }) + + it('does not require the fallback directory to exist when the path is reported', async () => { + drizzleKitWrites('db/0000_drop_a.sql') + const result = await scaffoldDrizzleMigration({ + name: 'drop_a', + outDir: 'drizzle', + sql: 'SELECT 1;', + }) + expect(existsSync(result.path)).toBe(true) + }) + + it('falls back to the scan when the banner is unrecognised, taking only a new exact-suffix file', async () => { + mkdirSync('drizzle') + writeFileSync('drizzle/0000_drop_a.sql', '-- applied earlier') + spawnSyncMock.mockImplementation(() => { + writeFileSync('drizzle/0001_drop_a.sql', '') + writeFileSync('drizzle/0002_drop_a_backup.sql', '') + return { status: 0, stdout: 'done', stderr: '' } + }) + const result = await scaffoldDrizzleMigration({ + name: 'drop_a', + outDir: 'drizzle', + sql: 'SELECT 1;', + }) + expect(result.path).toBe(join(root, 'drizzle/0001_drop_a.sql')) + expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe( + '-- applied earlier', + ) + }) + + it('refuses to overwrite a pre-existing same-stem file when nothing new appeared', async () => { + mkdirSync('drizzle') + writeFileSync('drizzle/0000_drop_a.sql', '-- applied earlier') + spawnSyncMock.mockReturnValue({ status: 0, stdout: 'done', stderr: '' }) + await expect( + scaffoldDrizzleMigration({ + name: 'drop_a', + outDir: 'drizzle', + sql: 'SELECT 1;', + }), + ).rejects.toThrow(/Refusing to overwrite/) + expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe( + '-- applied earlier', + ) + }) + + it('follows a reported path even when the banner is colourised', async () => { + mkdirSync('db') + spawnSyncMock.mockImplementation(() => { + writeFileSync('db/0000_drop_a.sql', '') + return { + status: 0, + stdout: + '[✓] Your SQL migration file \u001b[2m➜\u001b[0m \u001b[1mdb/0000_drop_a.sql\u001b[0m 🚀', + stderr: '', + } + }) + const result = await scaffoldDrizzleMigration({ + name: 'drop_a', + outDir: 'drizzle', + sql: 'SELECT 1;', + }) + expect(result.path).toBe(join(root, 'db/0000_drop_a.sql')) + }) + + it('refuses to overwrite a reported file that already has content', async () => { + mkdirSync('db') + writeFileSync('db/0000_old.sql', '-- applied earlier') + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: '➜ db/0000_old.sql', + stderr: '', + }) + await expect( + scaffoldDrizzleMigration({ + name: 'x', + outDir: 'drizzle', + sql: 'SELECT 1;', + }), + ).rejects.toThrow(/not an empty drizzle-kit scaffold/) + expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe('-- applied earlier') + }) + + it('surfaces drizzle-kit stdout and stderr on failure', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stdout: 'config error', + stderr: 'boom', + }) + await expect( + scaffoldDrizzleMigration({ name: 'x', outDir: 'drizzle', sql: '' }), + ).rejects.toThrow(/config error\nboom/) + }) +}) diff --git a/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts b/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts index f820d6c46e..c6863a421b 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts @@ -1,7 +1,11 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { readdir, writeFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import { + findGeneratedMigration, + parseReportedMigrationPath, +} from '@/commands/db/drizzle-migration.js' import { detectPackageManager, runnerArgv } from '@/commands/init/utils.js' /** @@ -10,12 +14,21 @@ import { detectPackageManager, runnerArgv } from '@/commands/init/utils.js' * does — `drizzle-kit generate --custom` creates the file and records the * journal entry / snapshot, then we overwrite the empty body with our SQL. * - * Used by `encrypt drop` to ship the plaintext-column drop migration in a - * shape `drizzle-kit migrate` actually picks up. + * Used by `encrypt drop` and the `decrypt` commands to ship the + * plaintext-column drop migration in a shape `drizzle-kit migrate` actually + * picks up. * - * Throws if `drizzle-kit` isn't on PATH or the generated file can't be - * located afterwards. Callers should fall back to the self-named-file - * approach for non-Drizzle projects. + * `drizzle.config.ts`'s `out` decides where drizzle-kit writes (we cannot pass + * `--out`: it switches drizzle-kit into CLI mode — see + * `generateDrizzleEqlMigration`), so the path drizzle-kit REPORTS is the + * authority on where the file landed. `outDir` is only the fallback place to + * look when that report is missing, and the fallback only accepts a file that + * did not exist before the run — otherwise an older same-stem migration in an + * unrelated directory would be overwritten. + * + * Throws if `drizzle-kit` can't run or the generated file can't be located + * afterwards. Callers should fall back to the self-named-file approach for + * non-Drizzle projects. */ export async function scaffoldDrizzleMigration(opts: { /** @@ -23,17 +36,13 @@ export async function scaffoldDrizzleMigration(opts: { * Drizzle prefixes with the next sequential index (`0003_.sql`). */ name: string - /** Drizzle's `out` directory, defaults to `./drizzle` if unset. */ + /** Fallback directory to scan if drizzle-kit's reported path is unusable. */ outDir: string /** SQL to write into the generated file. */ sql: string -}): Promise<{ path: string }> { +}): Promise<{ path: string; dir: string; requestedDir: string }> { const outDir = resolve(opts.outDir) - if (!existsSync(outDir)) { - throw new Error( - `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly (check drizzle.config.ts's \`out\`).`, - ) - } + const before = await listSqlFiles(outDir) // `drizzle-kit generate --custom` scaffolds an empty migration file with // the right prefix and records the journal/snapshot entry. spawnSync @@ -53,34 +62,59 @@ export async function scaffoldDrizzleMigration(opts: { { stdio: 'pipe', encoding: 'utf-8' }, ) if (cp.error || cp.status !== 0) { - const stderr = typeof cp.stderr === 'string' ? cp.stderr.trim() : undefined + const output = [cp.stdout, cp.stderr] + .map((stream) => (typeof stream === 'string' ? stream.trim() : '')) + .filter(Boolean) + .join('\n') throw new Error( - `Failed to scaffold Drizzle migration: ${stderr ?? cp.error?.message ?? 'unknown error'}`, + `Failed to scaffold Drizzle migration: ${output || cp.error?.message || 'unknown error'}`, ) } - const generatedPath = await findLatestNamedMigration(outDir, opts.name) + const generatedPath = + parseReportedMigrationPath(cp.stdout) ?? + (await findNewMigration(outDir, opts.name, before)) + // `generate --custom` leaves an empty file. Anything else is an existing + // migration (a banner line naming one, a wrapper's echo): never overwrite it. + if ((await readFile(generatedPath, 'utf-8')).trim() !== '') { + throw new Error( + `${generatedPath} is not an empty drizzle-kit scaffold; refusing to overwrite it. If drizzle-kit just journaled an empty "${opts.name}" migration, it is in the \`out\` of your drizzle.config.ts.`, + ) + } await writeFile(generatedPath, opts.sql, 'utf-8') - return { path: generatedPath } + return { + path: generatedPath, + dir: dirname(generatedPath), + requestedDir: outDir, + } +} + +async function listSqlFiles(dir: string): Promise> { + if (!existsSync(dir)) return new Set() + return new Set((await readdir(dir)).filter((f) => f.endsWith('.sql'))) } /** - * Find the most recent `0NNN_.sql` in the Drizzle out directory. - * Drizzle's flat numbered prefix means string-sort is the right order. + * Fallback when drizzle-kit's banner is not recognised: the newest exact + * `_.sql` in `outDir`, provided this run created it. A file that was + * already there is somebody's earlier (maybe applied) migration, and + * overwriting it would silently lose the new one. */ -async function findLatestNamedMigration( +async function findNewMigration( outDir: string, name: string, + before: Set, ): Promise { - const entries = await readdir(outDir) - const matching = entries - .filter((entry) => entry.endsWith('.sql') && entry.includes(name)) - .sort() - if (matching.length === 0) { + const found = await findGeneratedMigration(outDir, name).catch(() => { + throw new Error( + `Could not locate the migration drizzle-kit generated for "${name}". It was not reported on stdout and is not in ${outDir}. Check the \`out\` in your drizzle.config.ts — a custom migration may have been journaled there with an empty body.`, + ) + }) + if (before.has(basename(found))) { throw new Error( - `Could not find a migration matching "${name}" in ${outDir} after running drizzle-kit generate.`, + `drizzle-kit did not report where it wrote "${name}", and ${outDir} only holds a pre-existing file with that name (${found}). Refusing to overwrite it. Check the \`out\` in your drizzle.config.ts — an empty custom migration may have been journaled there.`, ) } - return join(outDir, matching[matching.length - 1] as string) + return found } diff --git a/languages/typescript/packages/cli/src/commands/encrypt/lib/migration-file.ts b/languages/typescript/packages/cli/src/commands/encrypt/lib/migration-file.ts index 5b0ef63177..f0acb93d06 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/lib/migration-file.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/lib/migration-file.ts @@ -1,7 +1,9 @@ import fs from 'node:fs' import path from 'node:path' +import * as p from '@clack/prompts' import { detectDrizzle } from '@/commands/db/detect.js' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { messages } from '@/messages.js' import { scaffoldDrizzleMigration } from '../drizzle-helper.js' /** Where a generated migration landed, and how to apply it. */ @@ -41,6 +43,21 @@ export async function writeMigrationFile(opts: { outDir: migrationsDir, sql: opts.sql, }) + // `drizzle.config.ts` decides where drizzle-kit writes, so an explicit + // --migrations-dir is only honoured as a fallback. Say so rather than leave + // the user believing the file is where they asked for it. + if ( + opts.migrationsDir !== undefined && + result.dir !== result.requestedDir + ) { + p.log.warn( + messages.eql.migrationDrizzleOutOverridden( + result.dir, + result.requestedDir, + '--migrations-dir', + ), + ) + } return { filePath: result.path, nextStep: `Apply with your usual Drizzle migrate command:\n ${runnerCommand(detectPackageManager(), 'drizzle-kit migrate')}`, diff --git a/languages/typescript/packages/cli/src/commands/eql/migration.ts b/languages/typescript/packages/cli/src/commands/eql/migration.ts index 8bec470905..bbc9920791 100644 --- a/languages/typescript/packages/cli/src/commands/eql/migration.ts +++ b/languages/typescript/packages/cli/src/commands/eql/migration.ts @@ -1,11 +1,14 @@ import { spawnSync } from 'node:child_process' import { existsSync, unlinkSync, writeFileSync } from 'node:fs' -import { readdir } from 'node:fs/promises' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { MIGRATIONS_SCHEMA_SQL } from '@cipherstash/migrate' import * as p from '@clack/prompts' import { CliExit } from '@/cli/exit.js' import { detectSupabaseProject } from '@/commands/db/detect.js' +import { + findGeneratedMigration, + parseReportedMigrationPath, +} from '@/commands/db/drizzle-migration.js' import { printNextSteps, SAFE_MIGRATION_NAME } from '@/commands/db/install.js' import { rewriteEncryptedAlterColumns } from '@/commands/db/rewrite-migrations.js' import { @@ -35,68 +38,6 @@ import { messages } from '@/messages.js' const DEFAULT_MIGRATION_NAME = 'install-eql' const DEFAULT_DRIZZLE_OUT = 'drizzle' -/** Find the most recently generated Drizzle migration matching the name. */ -export async function findGeneratedMigration( - outDir: string, - migrationName: string, -): Promise { - if (!existsSync(outDir)) { - throw new Error( - `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`, - ) - } - const migrationSuffix = `_${migrationName}.sql` - const matchingFiles = (await readdir(outDir)) - .filter((entry) => entry.endsWith(migrationSuffix)) - .sort() - if (matchingFiles.length === 0) { - throw new Error( - `Could not find a migration matching "${migrationName}" in ${outDir}`, - ) - } - return join(outDir, matchingFiles[matchingFiles.length - 1]) -} - -/** - * Pull the migration path out of drizzle-kit's own success line: - * - * [✓] Your SQL migration file ➜ drizzle/0000_install-eql.sql 🚀 - * - * Since we no longer pass `--out` (drizzle-kit rejects the run when we do — - * see `generateDrizzleEqlMigration`), `drizzle.config.ts` owns the output - * directory and this line is how we learn it. The path is printed relative to - * the cwd drizzle-kit ran in, which is ours. - * - * Returns `undefined` unless the file is actually there, so an unrecognised - * banner or a moved file falls through to the directory scan rather than - * pointing the SQL write at a path that does not exist. - */ -export function parseReportedMigrationPath( - stdout: string | undefined, -): string | undefined { - if (!stdout) return undefined - // Scanned per line, taking everything between the arrow and the LAST `.sql` - // on it, because an output directory may contain spaces — a `\S+` pattern - // truncates such a path, fails the existence check, and drops us into the - // fallback scan of an unrelated directory. - // - // Last line wins: one invocation writes one file, but a wrapper that echoes - // its own banner first should not shadow drizzle-kit's. - let found: string | undefined - for (const line of stdout.split(/\r?\n/)) { - const arrow = line.indexOf('➜') - if (arrow === -1) continue - const rest = line.slice(arrow + 1) - const end = rest.lastIndexOf('.sql') - if (end === -1) continue - const candidate = rest.slice(0, end + '.sql'.length).trim() - if (!candidate) continue - const abs = isAbsolute(candidate) ? candidate : resolve(candidate) - if (existsSync(abs)) found = abs - } - return found -} - /** * Does drizzle-kit's output say `DATABASE_URL` is *absent*, as opposed to * present and wrong? diff --git a/languages/typescript/packages/cli/src/messages.ts b/languages/typescript/packages/cli/src/messages.ts index a6a5776c03..24a6117656 100644 --- a/languages/typescript/packages/cli/src/messages.ts +++ b/languages/typescript/packages/cli/src/messages.ts @@ -175,8 +175,12 @@ export const messages = { * config wins and the user needs to know which one their file landed in — * silently using the real one would leave `--out` looking honoured. */ - migrationDrizzleOutOverridden: (configured: string, requested: string) => - `drizzle-kit wrote into ${configured}, not the --out you passed (${requested}) — the \`out\` in your drizzle.config.ts decides where migrations go, and stash follows it so the journal stays consistent. Change it there if you want them elsewhere.`, + migrationDrizzleOutOverridden: ( + configured: string, + requested: string, + flag = '--out', + ) => + `drizzle-kit wrote into ${configured}, not the ${flag} you passed (${requested}) — the \`out\` in your drizzle.config.ts decides where migrations go, and stash follows it so the journal stays consistent. Change it there if you want them elsewhere.`, /** * `--name` with `--supabase`. The Supabase filename is fixed because * duplicate detection matches on the `_cipherstash_eql.sql` suffix, so the diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index f45c156bf9..4ea65c39ca 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -643,6 +643,8 @@ Runs from the `backfilled` phase and drops the original plaintext ``. It ve Flags: `--table`, `--column`, `--migrations-dir `. +On Drizzle projects the migration is journaled by `drizzle-kit generate --custom`, so it lands in the `out` directory of `drizzle.config.ts` (the path drizzle-kit reports). `--migrations-dir` is only a fallback there, and the command warns when drizzle-kit wrote elsewhere. + ### Decrypt The reverse lifecycle: moving encrypted columns back to plaintext, for leaving CipherStash or un-encrypting a column. It mirrors `encrypt` step for step, on the same `@cipherstash/migrate` engine, and records its own events in `cipherstash.cs_migrations` — keyed by the **encrypted** column, so they never mix with the forward history kept under the plaintext name. Decryption is never charged. @@ -720,6 +722,8 @@ Generating records `encrypted-dropped`, which stops a second drop being generate Flags: `--table`, `--column`, `--plaintext-column`, `--migrations-dir `, `--uninstall-eql`. +On Drizzle projects the migration is journaled by `drizzle-kit generate --custom`, so it lands in the `out` directory of `drizzle.config.ts` (the path drizzle-kit reports). `--migrations-dir` is only a fallback there, and the command warns when drizzle-kit wrote elsewhere. + ### Deployment #### `env` From 6b32d1f1a237c5dfcfdaf9534881baf1c4959ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:56:44 +0000 Subject: [PATCH 2/4] fix(cli): accept drizzle-kit's comment-only custom migration scaffold The overwrite guard required a zero-byte file, but drizzle-kit writes '-- Custom SQL migration file, put your code below! --'. Treat blank or comment-only content as a scaffold and keep refusing real SQL. Tests now use the real placeholder. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7rkoV6gTBkz2uGSR3H --- .changeset/cli-drizzle-migrations-out.md | 2 +- .../encrypt/__tests__/drizzle-helper.test.ts | 27 ++++++++++--------- .../src/commands/encrypt/drizzle-helper.ts | 17 +++++++++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.changeset/cli-drizzle-migrations-out.md b/.changeset/cli-drizzle-migrations-out.md index 923aa56457..a0df9103bc 100644 --- a/.changeset/cli-drizzle-migrations-out.md +++ b/.changeset/cli-drizzle-migrations-out.md @@ -2,4 +2,4 @@ 'stash': patch --- -`stash encrypt drop` and `stash decrypt drop` now write the Drizzle migration to the directory drizzle-kit reports (the `out` in `drizzle.config.ts`) instead of scanning `--migrations-dir`. Previously, when the two differed, the SQL could be written over an older migration with the same name while drizzle-kit journaled an empty stub in the real `out`, so the DROP never shipped. When a scan fallback is needed it matches the exact `_.sql` suffix and refuses to overwrite any file that is not an empty drizzle-kit scaffold, and `--migrations-dir` now warns when drizzle-kit wrote elsewhere. +`stash encrypt drop` and `stash decrypt drop` now write the Drizzle migration to the directory drizzle-kit reports (the `out` in `drizzle.config.ts`) instead of scanning `--migrations-dir`. Previously, when the two differed, the SQL could be written over an older migration with the same name while drizzle-kit journaled an empty stub in the real `out`, so the DROP never shipped. When a scan fallback is needed it matches the exact `_.sql` suffix and refuses to overwrite any file that is not a drizzle-kit scaffold (blank or comment-only), and `--migrations-dir` now warns when drizzle-kit wrote elsewhere. diff --git a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts index b047c67926..7867d94444 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts @@ -24,6 +24,9 @@ import { scaffoldDrizzleMigration } from '../drizzle-helper.js' * follow the path drizzle-kit reports, never the --migrations-dir it scans as * a fallback (CIP-5162). */ +// What drizzle-kit's `generate --custom` actually writes. +const PLACEHOLDER = '-- Custom SQL migration file, put your code below! --\n' + describe('scaffoldDrizzleMigration', () => { let root: string let cwd: string @@ -41,7 +44,7 @@ describe('scaffoldDrizzleMigration', () => { function drizzleKitWrites(relPath: string) { spawnSyncMock.mockImplementation(() => { mkdirSync(join(relPath, '..'), { recursive: true }) - writeFileSync(relPath, '') + writeFileSync(relPath, PLACEHOLDER) return { status: 0, stdout: `[✓] Your SQL migration file ➜ ${relPath} 🚀\n`, @@ -52,7 +55,7 @@ describe('scaffoldDrizzleMigration', () => { it('writes to the directory drizzle-kit reports, not the scanned one', async () => { mkdirSync('drizzle') - writeFileSync('drizzle/0000_drop_users_email.sql', '-- applied earlier') + writeFileSync('drizzle/0000_drop_users_email.sql', 'ALTER TABLE t DROP COLUMN c;') drizzleKitWrites('src/db/migrations/0003_drop_users_email.sql') const result = await scaffoldDrizzleMigration({ @@ -66,7 +69,7 @@ describe('scaffoldDrizzleMigration', () => { ) expect(readFileSync(result.path, 'utf-8')).toContain('DROP COLUMN email') expect(readFileSync('drizzle/0000_drop_users_email.sql', 'utf-8')).toBe( - '-- applied earlier', + 'ALTER TABLE t DROP COLUMN c;', ) expect(result.dir).not.toBe(result.requestedDir) }) @@ -83,9 +86,9 @@ describe('scaffoldDrizzleMigration', () => { it('falls back to the scan when the banner is unrecognised, taking only a new exact-suffix file', async () => { mkdirSync('drizzle') - writeFileSync('drizzle/0000_drop_a.sql', '-- applied earlier') + writeFileSync('drizzle/0000_drop_a.sql', 'ALTER TABLE t DROP COLUMN c;') spawnSyncMock.mockImplementation(() => { - writeFileSync('drizzle/0001_drop_a.sql', '') + writeFileSync('drizzle/0001_drop_a.sql', PLACEHOLDER) writeFileSync('drizzle/0002_drop_a_backup.sql', '') return { status: 0, stdout: 'done', stderr: '' } }) @@ -96,13 +99,13 @@ describe('scaffoldDrizzleMigration', () => { }) expect(result.path).toBe(join(root, 'drizzle/0001_drop_a.sql')) expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe( - '-- applied earlier', + 'ALTER TABLE t DROP COLUMN c;', ) }) it('refuses to overwrite a pre-existing same-stem file when nothing new appeared', async () => { mkdirSync('drizzle') - writeFileSync('drizzle/0000_drop_a.sql', '-- applied earlier') + writeFileSync('drizzle/0000_drop_a.sql', 'ALTER TABLE t DROP COLUMN c;') spawnSyncMock.mockReturnValue({ status: 0, stdout: 'done', stderr: '' }) await expect( scaffoldDrizzleMigration({ @@ -112,14 +115,14 @@ describe('scaffoldDrizzleMigration', () => { }), ).rejects.toThrow(/Refusing to overwrite/) expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe( - '-- applied earlier', + 'ALTER TABLE t DROP COLUMN c;', ) }) it('follows a reported path even when the banner is colourised', async () => { mkdirSync('db') spawnSyncMock.mockImplementation(() => { - writeFileSync('db/0000_drop_a.sql', '') + writeFileSync('db/0000_drop_a.sql', PLACEHOLDER) return { status: 0, stdout: @@ -137,7 +140,7 @@ describe('scaffoldDrizzleMigration', () => { it('refuses to overwrite a reported file that already has content', async () => { mkdirSync('db') - writeFileSync('db/0000_old.sql', '-- applied earlier') + writeFileSync('db/0000_old.sql', 'ALTER TABLE t DROP COLUMN c;') spawnSyncMock.mockReturnValue({ status: 0, stdout: '➜ db/0000_old.sql', @@ -149,8 +152,8 @@ describe('scaffoldDrizzleMigration', () => { outDir: 'drizzle', sql: 'SELECT 1;', }), - ).rejects.toThrow(/not an empty drizzle-kit scaffold/) - expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe('-- applied earlier') + ).rejects.toThrow(/not a drizzle-kit scaffold/) + expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe('ALTER TABLE t DROP COLUMN c;') }) it('surfaces drizzle-kit stdout and stderr on failure', async () => { diff --git a/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts b/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts index c6863a421b..632b4a86c6 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/drizzle-helper.ts @@ -74,11 +74,13 @@ export async function scaffoldDrizzleMigration(opts: { const generatedPath = parseReportedMigrationPath(cp.stdout) ?? (await findNewMigration(outDir, opts.name, before)) - // `generate --custom` leaves an empty file. Anything else is an existing - // migration (a banner line naming one, a wrapper's echo): never overwrite it. - if ((await readFile(generatedPath, 'utf-8')).trim() !== '') { + // `generate --custom` leaves a comment-only placeholder (drizzle-kit writes + // `-- Custom SQL migration file, put your code below! --`). Anything with + // real SQL is an existing migration (a banner line naming one, a wrapper's + // echo): never overwrite it. + if (!isScaffoldOnly(await readFile(generatedPath, 'utf-8'))) { throw new Error( - `${generatedPath} is not an empty drizzle-kit scaffold; refusing to overwrite it. If drizzle-kit just journaled an empty "${opts.name}" migration, it is in the \`out\` of your drizzle.config.ts.`, + `${generatedPath} is not a drizzle-kit scaffold; refusing to overwrite it. If drizzle-kit just journaled a placeholder "${opts.name}" migration, it is in the \`out\` of your drizzle.config.ts.`, ) } await writeFile(generatedPath, opts.sql, 'utf-8') @@ -90,6 +92,13 @@ export async function scaffoldDrizzleMigration(opts: { } } +/** Blank, or nothing but `--` comment lines: no statement to lose. */ +function isScaffoldOnly(content: string): boolean { + return content + .split(/\r?\n/) + .every((line) => line.trim() === '' || line.trim().startsWith('--')) +} + async function listSqlFiles(dir: string): Promise> { if (!existsSync(dir)) return new Set() return new Set((await readdir(dir)).filter((f) => f.endsWith('.sql'))) From c6ce0c75b9ef9fbe93f21e1ff4f13e62ea0dc831 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:56:52 +0000 Subject: [PATCH 3/4] style(cli): format drizzle-helper test Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7rkoV6gTBkz2uGSR3H --- .../commands/encrypt/__tests__/drizzle-helper.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts index 7867d94444..69736fae59 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts @@ -55,7 +55,10 @@ describe('scaffoldDrizzleMigration', () => { it('writes to the directory drizzle-kit reports, not the scanned one', async () => { mkdirSync('drizzle') - writeFileSync('drizzle/0000_drop_users_email.sql', 'ALTER TABLE t DROP COLUMN c;') + writeFileSync( + 'drizzle/0000_drop_users_email.sql', + 'ALTER TABLE t DROP COLUMN c;', + ) drizzleKitWrites('src/db/migrations/0003_drop_users_email.sql') const result = await scaffoldDrizzleMigration({ @@ -153,7 +156,9 @@ describe('scaffoldDrizzleMigration', () => { sql: 'SELECT 1;', }), ).rejects.toThrow(/not a drizzle-kit scaffold/) - expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe('ALTER TABLE t DROP COLUMN c;') + expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe( + 'ALTER TABLE t DROP COLUMN c;', + ) }) it('surfaces drizzle-kit stdout and stderr on failure', async () => { From 398af2678d4cd3557a3f3a0c3dc2b16e3aba135a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:18:31 +0000 Subject: [PATCH 4/4] test(cli): resolve the temp root's real path in drizzle-helper test process.cwd() returns the physical path after chdir, so a symlinked TMPDIR made the expected-path assertions fail on correct output. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7rkoV6gTBkz2uGSR3H --- .../src/commands/encrypt/__tests__/drizzle-helper.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts index 69736fae59..dbe569c623 100644 --- a/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts +++ b/languages/typescript/packages/cli/src/commands/encrypt/__tests__/drizzle-helper.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs' @@ -31,7 +32,9 @@ describe('scaffoldDrizzleMigration', () => { let root: string let cwd: string beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'stash-scaffold-')) + // realpath: process.cwd() reports the physical path after chdir, so a + // symlinked TMPDIR would otherwise break the expected-path assertions. + root = realpathSync(mkdtempSync(join(tmpdir(), 'stash-scaffold-'))) cwd = process.cwd() process.chdir(root) spawnSyncMock.mockReset()