From 34432e98c9bd973d199695737cf8e2abee4b6100 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 28 Apr 2026 10:20:38 -0600 Subject: [PATCH] feat(cli): supabase migration --- .changeset/all-frogs-give.md | 5 + packages/cli/src/__tests__/installer.test.ts | 45 ++- .../src/__tests__/supabase-migration.test.ts | 270 ++++++++++++++++++ packages/cli/src/bin/stash.ts | 8 +- packages/cli/src/commands/db/detect.ts | 64 ++++- packages/cli/src/commands/db/install.ts | 263 ++++++++++++++++- .../cli/src/commands/db/supabase-migration.ts | 121 ++++++++ .../src/commands/init/providers/supabase.ts | 5 +- packages/cli/src/installer/index.ts | 46 +-- 9 files changed, 798 insertions(+), 29 deletions(-) create mode 100644 .changeset/all-frogs-give.md create mode 100644 packages/cli/src/__tests__/supabase-migration.test.ts create mode 100644 packages/cli/src/commands/db/supabase-migration.ts diff --git a/.changeset/all-frogs-give.md b/.changeset/all-frogs-give.md new file mode 100644 index 00000000..2af7efdb --- /dev/null +++ b/.changeset/all-frogs-give.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/cli": minor +--- + +Added --migration and --direct options to Supabase EQL install steps diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts index 892c4f54..2c0e0bb5 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -51,7 +51,10 @@ describe('EQLInstaller', () => { switch (queryCall) { // pg_roles query — not superuser case 1: - return { rows: [{ rolsuper: false, rolcreatedb: false }], rowCount: 1 } + return { + rows: [{ rolsuper: false, rolcreatedb: false }], + rowCount: 1, + } // has_database_privilege — no CREATE case 2: return { rows: [{ has_create: false }], rowCount: 1 } @@ -130,7 +133,10 @@ describe('EQLInstaller', () => { expect(mockQuery).toHaveBeenCalledWith('BEGIN') // The second query should be the bundled SQL (a large string) const sqlCall = mockQuery.mock.calls.find( - (call: string[]) => typeof call[0] === 'string' && call[0] !== 'BEGIN' && call[0] !== 'COMMIT', + (call: string[]) => + typeof call[0] === 'string' && + call[0] !== 'BEGIN' && + call[0] !== 'COMMIT', ) expect(sqlCall).toBeDefined() expect(sqlCall[0]).toContain('eql_v2') @@ -163,6 +169,41 @@ describe('EQLInstaller', () => { expect(mockQuery).toHaveBeenCalledWith('COMMIT') }) + it('grants Supabase permissions as a single SUPABASE_PERMISSIONS_SQL query', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + mockEnd.mockResolvedValue(undefined) + + const { EQLInstaller, SUPABASE_PERMISSIONS_SQL } = await import( + '@/installer/index.ts' + ) + const installer = new EQLInstaller({ + databaseUrl: 'postgresql://localhost:5432/test', + }) + + await installer.install({ supabase: true }) + + // Capture every query string that isn't a transaction control verb. + const otherCalls = mockQuery.mock.calls + .map((call: unknown[]) => call[0]) + .filter( + (sql: unknown): sql is string => + typeof sql === 'string' && + sql !== 'BEGIN' && + sql !== 'COMMIT' && + sql !== 'ROLLBACK', + ) + + // Two non-transaction queries: bundled EQL SQL, then permissions SQL. + expect(otherCalls).toHaveLength(2) + expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL) + // Permissions SQL must mention each role + the eql_v2 schema. + expect(SUPABASE_PERMISSIONS_SQL).toContain('eql_v2') + expect(SUPABASE_PERMISSIONS_SQL).toContain('anon') + expect(SUPABASE_PERMISSIONS_SQL).toContain('authenticated') + expect(SUPABASE_PERMISSIONS_SQL).toContain('service_role') + }) + it('rolls back on SQL execution failure', async () => { mockConnect.mockResolvedValue(undefined) mockEnd.mockResolvedValue(undefined) diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts new file mode 100644 index 00000000..47dfcd33 --- /dev/null +++ b/packages/cli/src/__tests__/supabase-migration.test.ts @@ -0,0 +1,270 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { detectSupabaseProject } from '../commands/db/detect.js' +import { + chooseSupabaseInstallMode, + validateInstallFlags, +} from '../commands/db/install.js' +import { + SUPABASE_EQL_MIGRATION_FILENAME, + writeSupabaseEqlMigration, +} from '../commands/db/supabase-migration.js' +import { SUPABASE_PERMISSIONS_SQL } from '../installer/index.js' + +describe('detectSupabaseProject', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-supa-detect-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('detects only config.toml', () => { + fs.mkdirSync(path.join(tmpDir, 'supabase'), { recursive: true }) + fs.writeFileSync(path.join(tmpDir, 'supabase', 'config.toml'), '') + + const info = detectSupabaseProject(tmpDir) + expect(info.hasConfigToml).toBe(true) + expect(info.hasMigrationsDir).toBe(false) + expect(info.migrationsDir).toBe( + path.resolve(tmpDir, 'supabase', 'migrations'), + ) + }) + + it('detects only the migrations directory', () => { + fs.mkdirSync(path.join(tmpDir, 'supabase', 'migrations'), { + recursive: true, + }) + + const info = detectSupabaseProject(tmpDir) + expect(info.hasConfigToml).toBe(false) + expect(info.hasMigrationsDir).toBe(true) + }) + + it('detects both config.toml and the migrations directory', () => { + fs.mkdirSync(path.join(tmpDir, 'supabase', 'migrations'), { + recursive: true, + }) + fs.writeFileSync(path.join(tmpDir, 'supabase', 'config.toml'), '') + + const info = detectSupabaseProject(tmpDir) + expect(info.hasConfigToml).toBe(true) + expect(info.hasMigrationsDir).toBe(true) + }) + + it('returns false flags when neither marker is present', () => { + const info = detectSupabaseProject(tmpDir) + expect(info.hasConfigToml).toBe(false) + expect(info.hasMigrationsDir).toBe(false) + }) + + it('honors a custom override path (relative + absolute)', () => { + const customRel = 'db/migrations' + fs.mkdirSync(path.join(tmpDir, customRel), { recursive: true }) + + const relInfo = detectSupabaseProject(tmpDir, customRel) + expect(relInfo.migrationsDir).toBe(path.resolve(tmpDir, customRel)) + expect(relInfo.hasMigrationsDir).toBe(true) + + const absPath = path.resolve(tmpDir, customRel) + const absInfo = detectSupabaseProject(tmpDir, absPath) + expect(absInfo.migrationsDir).toBe(absPath) + expect(absInfo.hasMigrationsDir).toBe(true) + }) + + it('treats a file at the migrations path as a missing directory', () => { + fs.mkdirSync(path.join(tmpDir, 'supabase'), { recursive: true }) + fs.writeFileSync(path.join(tmpDir, 'supabase', 'migrations'), 'not a dir') + + const info = detectSupabaseProject(tmpDir) + expect(info.hasMigrationsDir).toBe(false) + }) +}) + +describe('writeSupabaseEqlMigration', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-supa-write-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('writes the file at the well-known filename', async () => { + const migrationsDir = path.join(tmpDir, 'supabase', 'migrations') + + const result = await writeSupabaseEqlMigration({ migrationsDir }) + + expect(path.basename(result.path)).toBe(SUPABASE_EQL_MIGRATION_FILENAME) + expect(result.overwritten).toBe(false) + expect(fs.existsSync(result.path)).toBe(true) + }) + + it('writes EQL SQL plus the SUPABASE_PERMISSIONS_SQL block', async () => { + const migrationsDir = path.join(tmpDir, 'supabase', 'migrations') + const result = await writeSupabaseEqlMigration({ migrationsDir }) + + const contents = fs.readFileSync(result.path, 'utf-8') + // Header comment block + expect(contents).toMatch(/^--/) + expect(contents).toContain('CipherStash') + // EQL SQL body — the bundled supabase variant defines eql_v2. + expect(contents).toContain('eql_v2') + // Permissions block (single source of truth). + expect(contents).toContain(SUPABASE_PERMISSIONS_SQL.trim()) + }) + + it('creates the migrations directory if missing', async () => { + const migrationsDir = path.join(tmpDir, 'supabase', 'migrations') + expect(fs.existsSync(migrationsDir)).toBe(false) + + const result = await writeSupabaseEqlMigration({ migrationsDir }) + + expect(fs.statSync(migrationsDir).isDirectory()).toBe(true) + expect(fs.existsSync(result.path)).toBe(true) + }) + + it('throws when the file already exists and force is false', async () => { + const migrationsDir = path.join(tmpDir, 'supabase', 'migrations') + fs.mkdirSync(migrationsDir, { recursive: true }) + const existingPath = path.join( + migrationsDir, + SUPABASE_EQL_MIGRATION_FILENAME, + ) + fs.writeFileSync(existingPath, '-- existing') + + await expect(writeSupabaseEqlMigration({ migrationsDir })).rejects.toThrow( + /already exists/, + ) + + // Existing content untouched + expect(fs.readFileSync(existingPath, 'utf-8')).toBe('-- existing') + }) + + it('overwrites when force is true', async () => { + const migrationsDir = path.join(tmpDir, 'supabase', 'migrations') + fs.mkdirSync(migrationsDir, { recursive: true }) + const existingPath = path.join( + migrationsDir, + SUPABASE_EQL_MIGRATION_FILENAME, + ) + fs.writeFileSync(existingPath, '-- existing') + + const result = await writeSupabaseEqlMigration({ + migrationsDir, + force: true, + }) + + expect(result.overwritten).toBe(true) + expect(fs.readFileSync(result.path, 'utf-8')).not.toBe('-- existing') + expect(fs.readFileSync(result.path, 'utf-8')).toContain('eql_v2') + }) + + it('sorts before realistic Supabase-style migration filenames', () => { + const filenames = [ + SUPABASE_EQL_MIGRATION_FILENAME, + '20251015120000_users.sql', + '99999999999999_other.sql', + ] + const sorted = [...filenames].sort() + expect(sorted[0]).toBe(SUPABASE_EQL_MIGRATION_FILENAME) + }) +}) + +describe('validateInstallFlags', () => { + it('returns null for an empty options object', () => { + expect(validateInstallFlags({})).toBeNull() + }) + + it('returns null when --supabase is paired with --migration', () => { + expect(validateInstallFlags({ supabase: true, migration: true })).toBeNull() + }) + + it('returns null when --supabase is paired with --direct', () => { + expect(validateInstallFlags({ supabase: true, direct: true })).toBeNull() + }) + + it('rejects --migration without --supabase', () => { + const err = validateInstallFlags({ migration: true }) + expect(err).toMatch(/--migration/) + expect(err).toMatch(/--supabase/) + }) + + it('rejects --direct without --supabase', () => { + const err = validateInstallFlags({ direct: true }) + expect(err).toMatch(/--direct/) + expect(err).toMatch(/--supabase/) + }) + + it('rejects --migrations-dir without --supabase', () => { + const err = validateInstallFlags({ migrationsDir: 'db/migrations' }) + expect(err).toMatch(/--migrations-dir/) + expect(err).toMatch(/--supabase/) + }) + + it('rejects --migration AND --direct together', () => { + const err = validateInstallFlags({ + supabase: true, + migration: true, + direct: true, + }) + expect(err).toMatch(/mutually exclusive/i) + }) + + it('does NOT auto-imply --supabase from --migration', () => { + // Even with --supabase: false explicitly, --migration must error. + const err = validateInstallFlags({ supabase: false, migration: true }) + expect(err).not.toBeNull() + }) +}) + +describe('chooseSupabaseInstallMode', () => { + const projectWith = { + hasMigrationsDir: true, + hasConfigToml: true, + migrationsDir: '/tmp/x', + } + const projectWithout = { + hasMigrationsDir: false, + hasConfigToml: false, + migrationsDir: '/tmp/x', + } + + it('honors explicit --migration regardless of TTY or detection', () => { + expect( + chooseSupabaseInstallMode({ migration: true }, projectWithout, true), + ).toBe('migration') + expect( + chooseSupabaseInstallMode({ migration: true }, projectWithout, false), + ).toBe('migration') + }) + + it('honors explicit --direct regardless of TTY or detection', () => { + expect(chooseSupabaseInstallMode({ direct: true }, projectWith, true)).toBe( + 'direct', + ) + expect( + chooseSupabaseInstallMode({ direct: true }, projectWith, false), + ).toBe('direct') + }) + + it('returns null in TTY mode when neither sub-flag is set (caller should prompt)', () => { + expect(chooseSupabaseInstallMode({}, projectWith, true)).toBeNull() + expect(chooseSupabaseInstallMode({}, projectWithout, true)).toBeNull() + }) + + it('non-interactive: defaults to migration when supabase/migrations exists', () => { + expect(chooseSupabaseInstallMode({}, projectWith, false)).toBe('migration') + }) + + it('non-interactive: defaults to direct when supabase/migrations is missing', () => { + expect(chooseSupabaseInstallMode({}, projectWithout, false)).toBe('direct') + }) +}) diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 6212d79e..e16bd8e3 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -84,10 +84,13 @@ Init Flags: --drizzle Use Drizzle-specific setup flow DB Flags: - --force (install) Reinstall even if already installed + --force (install) Reinstall / overwrite even if already installed --dry-run (install, push, upgrade) Show what would happen without making changes --supabase (install, upgrade, validate) Use Supabase-compatible mode (auto-detected from DATABASE_URL) --drizzle (install) Generate a Drizzle migration instead of direct install (auto-detected from project) + --migration (install, requires --supabase) Write a Supabase migration file instead of running SQL directly + --direct (install, requires --supabase) Run the SQL directly against the database (mutually exclusive with --migration) + --migrations-dir (install, requires --supabase) Override the Supabase migrations directory (default: supabase/migrations) --exclude-operator-family (install, upgrade, validate) Skip operator family creation --latest (install, upgrade) Fetch the latest EQL from GitHub @@ -154,6 +157,9 @@ async function runDbCommand( latest: flags.latest, name: values.name, out: values.out, + migration: flags.migration, + direct: flags.direct, + migrationsDir: values['migrations-dir'], }) break case 'upgrade': diff --git a/packages/cli/src/commands/db/detect.ts b/packages/cli/src/commands/db/detect.ts index d2618df4..61558deb 100644 --- a/packages/cli/src/commands/db/detect.ts +++ b/packages/cli/src/commands/db/detect.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readFileSync, statSync } from 'node:fs' +import { isAbsolute, resolve } from 'node:path' /** * Return true when the connection string points at a Supabase-hosted Postgres. @@ -26,6 +26,66 @@ export function detectSupabase(databaseUrl: string | undefined): boolean { ) } +/** + * Information about the Supabase project layout in the current working + * directory. Pure filesystem facts — no DB calls and no I/O beyond `existsSync` + * / `statSync`. + */ +export interface SupabaseProjectInfo { + /** + * Whether the migrations directory exists AND is a directory. Used to pick + * the migration-vs-direct default in the `db install --supabase` prompt. + */ + hasMigrationsDir: boolean + /** + * Whether `supabase/config.toml` exists. Informational only — it doesn't + * influence the prompt default but is useful for diagnostics. + */ + hasConfigToml: boolean + /** + * Absolute path to the migrations directory we'd write into. Defaults to + * `/supabase/migrations`, or `override` (resolved against `cwd` when + * relative) when supplied via `--migrations-dir`. + */ + migrationsDir: string +} + +/** + * Inspect the working directory for Supabase CLI scaffolding. + * + * IMPORTANT: this is a hint for choosing the install-mode prompt default — + * it does NOT enable `--supabase`. The user must pass `--supabase` explicitly + * for any of the migration-file flow to activate. + * + * @param cwd - Project root to inspect. + * @param override - Optional `--migrations-dir` override. Absolute paths are + * used as-is; relative paths are resolved against `cwd`. + */ +export function detectSupabaseProject( + cwd: string, + override?: string, +): SupabaseProjectInfo { + const migrationsDir = override + ? isAbsolute(override) + ? override + : resolve(cwd, override) + : resolve(cwd, 'supabase', 'migrations') + + const hasMigrationsDir = existsAsDirectory(migrationsDir) + const hasConfigToml = existsSync(resolve(cwd, 'supabase', 'config.toml')) + + return { hasMigrationsDir, hasConfigToml, migrationsDir } +} + +function existsAsDirectory(path: string): boolean { + if (!existsSync(path)) return false + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + /** * Return true when the project uses Drizzle. * diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index a1cbd9ab..be0077e2 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -10,8 +10,17 @@ import { } from '@/installer/index.js' import * as p from '@clack/prompts' import { ensureStashConfig } from './config-scaffold.js' -import { detectDrizzle, detectSupabase } from './detect.js' +import { + type SupabaseProjectInfo, + detectDrizzle, + detectSupabase, + detectSupabaseProject, +} from './detect.js' import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' +import { + SUPABASE_EQL_MIGRATION_FILENAME, + writeSupabaseEqlMigration, +} from './supabase-migration.js' const DEFAULT_MIGRATION_NAME = 'install-eql' const DEFAULT_DRIZZLE_OUT = 'drizzle' @@ -29,11 +38,40 @@ export interface InstallOptions { latest?: boolean name?: string out?: string + /** + * Write the EQL install SQL into a Supabase migration file instead of + * running it directly against the database. Requires `--supabase`. + */ + migration?: boolean + /** + * Run the EQL install SQL directly against the database (current behavior). + * Requires `--supabase`. Mutually exclusive with `--migration`. + */ + direct?: boolean + /** + * Override the directory the Supabase migration file is written into. + * Defaults to `/supabase/migrations`. + */ + migrationsDir?: string } +/** Resolved install mode for the Supabase non-Drizzle branch. */ +export type SupabaseInstallMode = 'migration' | 'direct' + export async function installCommand(options: InstallOptions) { p.intro('npx @cipherstash/cli db install') + // Validate mutually-exclusive / supabase-required flags BEFORE doing any + // I/O. `--migration` and `--direct` only make sense in the Supabase flow; + // they must NOT implicitly enable `--supabase`. (Strong product preference + // — auto-enabling here has bitten users before.) + const flagError = validateInstallFlags(options) + if (flagError) { + p.log.error(flagError) + p.outro('Installation aborted.') + process.exit(1) + } + // Scaffold stash.config.ts if missing. `db install` is the single command // that gets a project from zero to installed EQL — no separate setup step // (CIP-2986). @@ -64,6 +102,39 @@ export async function installCommand(options: InstallOptions) { return } + // Supabase non-Drizzle path: pick between writing a migration file and + // running SQL directly. Detection of `supabase/migrations/` only seeds the + // prompt default — it never enables `--supabase`. Direct install is the + // historical default and remains the fallback when nothing else applies. + if (resolved.supabase) { + const projectInfo = detectSupabaseProject( + process.cwd(), + options.migrationsDir, + ) + const mode = await resolveSupabaseInstallMode(options, projectInfo) + + if (mode === 'migration') { + // CIP: --latest in the migration path is not yet implemented. Loading + // the bundled SQL works today; downloading from GitHub adds an extra + // moving part we'd rather defer until someone needs it. + if (options.latest) { + p.log.error( + '`db install --supabase --migration --latest` is not yet supported. Please open an issue at https://github.com/cipherstash/stack/issues if you need this.', + ) + p.outro('Installation aborted.') + process.exit(1) + } + + await writeSupabaseMigrationFile(s, { + projectInfo, + force: options.force, + dryRun: options.dryRun, + }) + return + } + // mode === 'direct' — fall through to existing direct-install behavior. + } + if (options.dryRun) { p.log.info('Dry run — no changes will be made.') const source = options.latest @@ -359,6 +430,196 @@ async function generateDrizzleMigration( p.outro('Done!') } +/** + * Validate flag combinations that we can detect without doing any I/O. + * + * Rules: + * - `--migration` and `--direct` are mutually exclusive. + * - `--migration`, `--direct`, and `--migrations-dir` each REQUIRE + * `--supabase`. They do NOT auto-imply it. + * + * Returns a user-facing error message, or `null` when the flags are valid. + */ +export function validateInstallFlags(options: InstallOptions): string | null { + if (options.migration && options.direct) { + return '`--migration` and `--direct` are mutually exclusive. Pick one.' + } + + const subFlag = + options.migration === true + ? '--migration' + : options.direct === true + ? '--direct' + : options.migrationsDir !== undefined + ? '--migrations-dir' + : null + + if (subFlag !== null && options.supabase !== true) { + return `\`${subFlag}\` requires \`--supabase\`. Re-run with \`db install --supabase ${subFlag}\`.` + } + + return null +} + +/** + * Pick the Supabase install mode purely from inputs. No I/O, no prompts — + * easy to unit-test and to reason about. + * + * - Explicit `--migration` or `--direct` always wins. + * - Otherwise, when stdin isn't a TTY, default to `migration` if the + * `supabase/migrations/` directory exists and `direct` otherwise. This is + * the same heuristic the prompt uses for its default — keeps interactive + * and non-interactive runs aligned. + * - When stdin IS a TTY and neither flag is set, returns `null` to signal + * that the caller should prompt. + */ +export function chooseSupabaseInstallMode( + options: Pick, + projectInfo: SupabaseProjectInfo, + isTTY: boolean, +): SupabaseInstallMode | null { + if (options.migration) return 'migration' + if (options.direct) return 'direct' + if (!isTTY) return projectInfo.hasMigrationsDir ? 'migration' : 'direct' + return null +} + +/** + * Resolve the install mode, prompting the user when stdin is a TTY and + * neither sub-flag was passed. Pure logic lives in + * {@link chooseSupabaseInstallMode}; this is the I/O wrapper. + */ +async function resolveSupabaseInstallMode( + options: InstallOptions, + projectInfo: SupabaseProjectInfo, +): Promise { + const isTTY = Boolean(process.stdin.isTTY) && process.env.CI !== 'true' + const decided = chooseSupabaseInstallMode(options, projectInfo, isTTY) + + if (decided !== null) { + if ( + !isTTY && + options.migration === undefined && + options.direct === undefined + ) { + // Make non-interactive choices visible — surprise auto-decisions are a + // common debugging headache. + p.log.info( + projectInfo.hasMigrationsDir + ? `Detected ${projectInfo.migrationsDir} — defaulting to --migration in non-interactive mode.` + : 'No supabase/migrations directory found — defaulting to --direct in non-interactive mode.', + ) + } + return decided + } + + const defaultMode: SupabaseInstallMode = projectInfo.hasMigrationsDir + ? 'migration' + : 'direct' + + const choice = await p.select({ + message: 'How should EQL be installed?', + initialValue: defaultMode, + options: [ + { + value: 'migration', + label: 'Write a Supabase migration file', + hint: projectInfo.hasMigrationsDir + ? 'recommended — works with `supabase db reset`' + : 'creates supabase/migrations/ if missing', + }, + { + value: 'direct', + label: 'Run the SQL directly against the database', + hint: 'fastest, but `supabase db reset` will not re-install EQL', + }, + ], + }) + + if (p.isCancel(choice)) { + p.cancel('Installation cancelled.') + process.exit(0) + } + + return choice +} + +/** + * Write the `00000000000000_cipherstash_eql.sql` migration to the project's + * Supabase migrations directory. Mirrors the structure of the Drizzle + * migration helper for parity in the user-facing flow. + */ +async function writeSupabaseMigrationFile( + s: ReturnType, + opts: { + projectInfo: SupabaseProjectInfo + force?: boolean + dryRun?: boolean + }, +): Promise { + const { projectInfo, force, dryRun } = opts + const targetPath = join( + projectInfo.migrationsDir, + SUPABASE_EQL_MIGRATION_FILENAME, + ) + + if (dryRun) { + p.log.info('Dry run — no changes will be made.') + p.note( + [ + `Would write Supabase migration to:\n ${targetPath}`, + '', + 'Apply with one of:', + ' supabase db reset # local', + ' supabase migration up # remote (or push)', + ].join('\n'), + 'Dry Run', + ) + p.outro('Dry run complete.') + return + } + + s.start('Writing CipherStash EQL migration...') + let result: { path: string; overwritten: boolean } + try { + result = await writeSupabaseEqlMigration({ + migrationsDir: projectInfo.migrationsDir, + force, + }) + } catch (error) { + s.stop('Failed to write Supabase migration.') + const message = error instanceof Error ? error.message : String(error) + p.log.error(message) + if (!force && message.includes('already exists')) { + p.log.info( + 'Re-run with --force to overwrite the existing migration file.', + ) + } + p.outro('Installation aborted.') + process.exit(1) + } + + s.stop( + result.overwritten + ? `Overwrote ${result.path}` + : `Migration created: ${result.path}`, + ) + + p.note( + [ + 'Apply the migration to install EQL:', + '', + ' supabase db reset # local — re-runs all migrations', + ' supabase migration up # remote — applies pending migrations', + '', + 'EQL is NOT installed yet. The SQL only runs when Supabase applies the migration.', + ].join('\n'), + 'Next Steps', + ) + printNextSteps() + p.outro('Done!') +} + /** * Find the most recently generated migration file matching the given name. * Drizzle-kit generates flat SQL files like `0000_install-eql.sql`. diff --git a/packages/cli/src/commands/db/supabase-migration.ts b/packages/cli/src/commands/db/supabase-migration.ts new file mode 100644 index 00000000..2da49988 --- /dev/null +++ b/packages/cli/src/commands/db/supabase-migration.ts @@ -0,0 +1,121 @@ +import { existsSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + SUPABASE_PERMISSIONS_SQL, + loadBundledEqlSql, +} from '@/installer/index.js' + +/** + * Filename of the Supabase migration that installs CipherStash EQL. + * + * Supabase orders migrations lexically by the `YYYYMMDDHHMMSS_` prefix; an + * all-zero prefix is guaranteed to sort before any real timestamp, so this + * file always runs first on `supabase db reset` and `supabase migration up`. + * That ordering is the whole point of this code path — without it, user + * migrations referencing `eql_v2_encrypted` blow up because the EQL types + * aren't installed yet. + */ +export const SUPABASE_EQL_MIGRATION_FILENAME = + '00000000000000_cipherstash_eql.sql' + +/** + * Header comment block prepended to the generated migration. Explains *why* + * this file exists for future maintainers reading their own migrations + * directory. + */ +const MIGRATION_HEADER = `-- CipherStash EQL — installed by \`npx @cipherstash/cli db install --supabase --migration\`. +-- +-- This migration installs the CipherStash Encrypt Query Language (EQL) types, +-- functions, and operators into the \`eql_v2\` schema, then grants Supabase's +-- \`anon\`, \`authenticated\`, and \`service_role\` roles the access they need. +-- +-- The all-zero \`YYYYMMDDHHMMSS\` prefix is intentional: Supabase orders +-- migrations lexically, so this file runs before any user migration that +-- references the \`eql_v2_encrypted\` type. Do not rename it. +-- +-- To upgrade EQL, re-run the install command — it will refuse to overwrite +-- this file unless you pass --force. +-- +-- Docs: https://cipherstash.com/docs/stack/cipherstash/supabase +` + +export interface WriteSupabaseEqlMigrationOptions { + /** + * Absolute path to the directory the migration file should be written into. + * Created (recursively) if it doesn't already exist. + */ + migrationsDir: string + /** + * Overwrite an existing migration file at this path. When `false` (default) + * an existing file causes the function to throw. + */ + force?: boolean + /** + * Whether to use the no-operator-family EQL bundle. Supabase always wants + * this — we expose the flag for symmetry with the runtime install path and + * to leave room for future provider variants. + */ + excludeOperatorFamily?: boolean +} + +export interface WriteSupabaseEqlMigrationResult { + /** Absolute path to the migration file that was written. */ + path: string + /** Whether an existing file at `path` was overwritten. */ + overwritten: boolean +} + +/** + * Generate the `/00000000000000_cipherstash_eql.sql` migration. + * + * The file body is, in order: + * 1. {@link MIGRATION_HEADER} — explains why the file exists. + * 2. The bundled `cipherstash-encrypt-supabase.sql` install script. + * 3. {@link SUPABASE_PERMISSIONS_SQL} — the same grants the runtime install + * path issues. One source of truth for both code paths. + * + * @throws if the target file already exists and `force` is `false`. + */ +export async function writeSupabaseEqlMigration( + options: WriteSupabaseEqlMigrationOptions, +): Promise { + const { + migrationsDir, + force = false, + excludeOperatorFamily = false, + } = options + + const targetPath = join(migrationsDir, SUPABASE_EQL_MIGRATION_FILENAME) + const alreadyExists = existsSync(targetPath) + + if (alreadyExists && !force) { + throw new Error( + `Refusing to overwrite ${targetPath}: file already exists. Re-run with --force to overwrite.`, + ) + } + + // The runtime install always uses `cipherstash-encrypt-supabase.sql` for + // Supabase, which is the no-operator-family variant. We pass both flags so + // intent is explicit and `loadBundledEqlSql` resolves the supabase file + // even if the underlying selection rules ever change. + const eqlSql = loadBundledEqlSql({ + supabase: true, + excludeOperatorFamily: excludeOperatorFamily || true, + }) + + const body = [ + MIGRATION_HEADER, + '', + eqlSql.trimEnd(), + '', + '-- Grant access to Supabase roles (anon, authenticated, service_role).', + SUPABASE_PERMISSIONS_SQL.trimEnd(), + '', + ].join('\n') + + await mkdir(migrationsDir, { recursive: true }) + await writeFile(targetPath, body, 'utf-8') + + return { path: targetPath, overwritten: alreadyExists } +} diff --git a/packages/cli/src/commands/init/providers/supabase.ts b/packages/cli/src/commands/init/providers/supabase.ts index 41019475..f9efc9bd 100644 --- a/packages/cli/src/commands/init/providers/supabase.ts +++ b/packages/cli/src/commands/init/providers/supabase.ts @@ -15,7 +15,10 @@ export function createSupabaseProvider(): InitProvider { { value: 'raw-sql', label: 'Raw SQL / pg' }, ], getNextSteps(state: InitState): string[] { - const steps = ['Set up your database: npx @cipherstash/cli db install --supabase'] + const steps = [ + 'Install EQL: npx @cipherstash/cli db install --supabase (prompts for migration vs direct)', + 'Apply it: supabase db reset (local) or supabase migration up (remote)', + ] const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 68b46f98..bfd75fa9 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -8,6 +8,25 @@ const EQL_INSTALL_NO_OPERATOR_FAMILY_URL = 'https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql' const EQL_SCHEMA_NAME = 'eql_v2' +/** + * SQL block that grants the EQL schema, tables, routines, and sequences to + * Supabase's built-in roles (`anon`, `authenticated`, `service_role`). + * + * Supabase uses dedicated roles that don't own the schema, so explicit grants + * are required. We expose this as a single multi-statement string so it can be + * executed in one `client.query()` (Postgres accepts multi-statement strings) + * AND embedded directly into a Supabase migration file. One source of truth + * for both the runtime install path and the generated migration file. + */ +export const SUPABASE_PERMISSIONS_SQL = `GRANT USAGE ON SCHEMA ${EQL_SCHEMA_NAME} TO anon, authenticated, service_role; +GRANT SELECT ON ALL TABLES IN SCHEMA ${EQL_SCHEMA_NAME} TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${EQL_SCHEMA_NAME} TO anon, authenticated, service_role; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA ${EQL_SCHEMA_NAME} TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT SELECT ON TABLES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT USAGE ON SEQUENCES TO anon, authenticated, service_role; +` + /** * Get the directory of the current file, supporting both ESM and CJS. */ @@ -274,30 +293,13 @@ export class EQLInstaller { * Grant Supabase roles access to the eql_v2 schema. * * Supabase uses dedicated roles (anon, authenticated, service_role) that - * don't own the schema, so explicit grants are required. + * don't own the schema, so explicit grants are required. Issues + * {@link SUPABASE_PERMISSIONS_SQL} as a single multi-statement query — + * Postgres accepts that and it keeps the SQL identical to what we'd write + * into a Supabase migration file. */ private async grantSupabasePermissions(client: pg.Client): Promise { - const roles = 'anon, authenticated, service_role' - - await client.query(`GRANT USAGE ON SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`) - await client.query( - `GRANT SELECT ON ALL TABLES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`, - ) - await client.query( - `GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`, - ) - await client.query( - `GRANT USAGE ON ALL SEQUENCES IN SCHEMA ${EQL_SCHEMA_NAME} TO ${roles}`, - ) - await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT SELECT ON TABLES TO ${roles}`, - ) - await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT EXECUTE ON ROUTINES TO ${roles}`, - ) - await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${EQL_SCHEMA_NAME} GRANT USAGE ON SEQUENCES TO ${roles}`, - ) + await client.query(SUPABASE_PERMISSIONS_SQL) } /**