Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/all-frogs-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/cli": minor
---

Added --migration and --direct options to Supabase EQL install steps
45 changes: 43 additions & 2 deletions packages/cli/src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
270 changes: 270 additions & 0 deletions packages/cli/src/__tests__/supabase-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
8 changes: 7 additions & 1 deletion packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> (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

Expand Down Expand Up @@ -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':
Expand Down
Loading
Loading