From a47a63e40bb78e05505fca577d2c8a48d50c6a70 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Fri, 17 Apr 2026 08:24:26 -0600 Subject: [PATCH] feat(cli): onboarding fixes batch for launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes or addresses 16 Linear tickets from the end-to-end onboarding test (CIP-2982 … CIP-3001). Scope is `packages/cli` and `packages/stack`. Highlights - `db install` absorbs the old `db setup` scaffolding and is now the single canonical command. `db setup` is removed. - `db install` auto-detects Supabase (from DATABASE_URL host) and Drizzle (from drizzle.config.* or package.json deps), so the `--supabase` and `--drizzle` flags default on when relevant. - Non-superuser roles now auto-fall back to the no-operator-family (OPE) install variant instead of aborting, which unblocks Supabase/Neon/RDS onboarding. - `--supabase` now threads through the `--drizzle` migration path (it was silently dropped before). - Drizzle's `encryptedType` emits `"public"."eql_v2_encrypted"` so generated migrations no longer contain `"undefined"."eql_v2_encrypted"`. - Drizzle migrations that use `ALTER COLUMN ... SET DATA TYPE` (which fails because there's no implicit cast) are automatically rewritten to ADD + UPDATE placeholder + DROP + RENAME — safe on empty tables, with a comment reminding users to backfill via encryptModel for non-empty tables. Rewrite runs in both the CLI drizzle path and the wizard's post-agent step. - Wizard now: prompts to install Claude skills into `./.claude/skills/`, writes a timestamped markdown log to `.cipherstash/wizard-log.md`, and scans src/app, src/lib, app/, lib/ for encrypt/decrypt call sites (report-only, no file mutations). - `init` streams npm/pnpm/yarn output during package install instead of hiding it behind a silent spinner. - CLI loads `.env.local` before `.env` (Next.js precedence). - `db install` prints a "what next" block pointing at the wizard and direct SDK usage. - Forge branding removed from user-facing CLI strings. - Experimental `env` command scaffold gated behind `STASH_EXPERIMENTAL_ENV_CMD=1`. The CTS mint endpoint is TBD; command returns a clear "not ready" message until then. Known-issue markers - CIP-2996 (auth login profile dir mismatch) is upstream in `@cipherstash/auth`. TODO marker added in login.ts; bump the catalog pin once a fixed release ships. Tests - 20 new tests: `detect.test.ts` (detectSupabase/detectDrizzle) and `rewrite-migrations.test.ts` (ALTER COLUMN rewrite cases). - Full CLI suite: 124 pass / 5 skipped. - Stack tests unchanged: `drizzle/index.ts` edit verified via existing drizzle-operators suite. Auth-gated integration tests fail the same way on `main` without a CTS token. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/README.md | 50 +--- packages/cli/src/__tests__/detect.test.ts | 88 +++++++ .../src/__tests__/rewrite-migrations.test.ts | 105 ++++++++ packages/cli/src/bin/stash.ts | 62 ++--- packages/cli/src/commands/auth/login.ts | 10 +- .../cli/src/commands/db/config-scaffold.ts | 106 +++++++++ packages/cli/src/commands/db/detect.ts | 67 ++++++ packages/cli/src/commands/db/install.ts | 186 ++++++++++++--- .../cli/src/commands/db/rewrite-migrations.ts | 79 ++++++ packages/cli/src/commands/db/setup.ts | 224 ------------------ packages/cli/src/commands/env/index.ts | 91 +++++++ packages/cli/src/commands/index.ts | 2 +- .../cli/src/commands/init/providers/base.ts | 2 +- .../src/commands/init/providers/drizzle.ts | 2 +- .../src/commands/init/providers/supabase.ts | 2 +- .../src/commands/init/steps/install-forge.ts | 30 ++- .../cli/src/commands/wizard/lib/changelog.ts | 97 ++++++++ .../src/commands/wizard/lib/install-skills.ts | 100 ++++++++ .../cli/src/commands/wizard/lib/post-agent.ts | 51 +++- .../src/commands/wizard/lib/prerequisites.ts | 2 +- .../commands/wizard/lib/wire-call-sites.ts | 175 ++++++++++++++ packages/cli/src/commands/wizard/run.ts | 90 ++++++- packages/cli/src/installer/index.ts | 34 ++- packages/cli/tsup.config.ts | 3 + packages/stack/src/drizzle/index.ts | 26 +- 25 files changed, 1323 insertions(+), 361 deletions(-) create mode 100644 packages/cli/src/__tests__/detect.test.ts create mode 100644 packages/cli/src/__tests__/rewrite-migrations.test.ts create mode 100644 packages/cli/src/commands/db/config-scaffold.ts create mode 100644 packages/cli/src/commands/db/detect.ts create mode 100644 packages/cli/src/commands/db/rewrite-migrations.ts delete mode 100644 packages/cli/src/commands/db/setup.ts create mode 100644 packages/cli/src/commands/env/index.ts create mode 100644 packages/cli/src/commands/wizard/lib/changelog.ts create mode 100644 packages/cli/src/commands/wizard/lib/install-skills.ts create mode 100644 packages/cli/src/commands/wizard/lib/wire-call-sites.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 520ef5d1..e4eb8648 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,8 +12,8 @@ The single CLI for CipherStash. It handles authentication, project initializatio ```bash npm install -D @cipherstash/cli npx @cipherstash/cli auth login # authenticate with CipherStash -npx @cipherstash/cli init # scaffold encryption schema and stash.config.ts -npx @cipherstash/cli db setup # connect to your database and install EQL +npx @cipherstash/cli init # scaffold encryption schema and install dependencies +npx @cipherstash/cli db install # scaffold stash.config.ts (if missing) and install EQL npx @cipherstash/cli wizard # AI agent wires encryption into your codebase ``` @@ -21,7 +21,7 @@ What each step does: - `auth login` — opens a browser-based device code flow and saves a token to `~/.cipherstash/auth.json`. - `init` — generates your encryption client file and installs `@cipherstash/cli` as a dev dependency. Pass `--supabase` or `--drizzle` for provider-specific setup. -- `db setup` — detects your encryption client, prompts for a database URL, writes `stash.config.ts`, and installs EQL extensions. +- `db install` — detects your encryption client, writes `stash.config.ts` if it's missing, and installs EQL extensions in a single step. - `wizard` — reads your codebase with an AI agent (uses the CipherStash-hosted LLM gateway, no Anthropic API key required) and modifies your schema files in place. --- @@ -30,13 +30,13 @@ What each step does: ``` npx @cipherstash/cli init - └── npx @cipherstash/cli db setup + └── npx @cipherstash/cli db install └── npx @cipherstash/cli wizard ← fast path: AI edits your files OR Edit schema files by hand ← escape hatch ``` -`npx @cipherstash/cli wizard` is the recommended path after `db setup`. It detects your framework (Drizzle, Supabase, Prisma, raw SQL), introspects your database, and integrates encryption directly into your existing schema definitions. If you prefer to write the schema by hand, skip the wizard and edit your encryption client file directly. +`npx @cipherstash/cli wizard` is the recommended path after `db install`. It detects your framework (Drizzle, Supabase, Prisma, raw SQL), introspects your database, and integrates encryption directly into your existing schema definitions. If you prefer to write the schema by hand, skip the wizard and edit your encryption client file directly. --- @@ -60,7 +60,7 @@ export default defineConfig({ The CLI loads `.env` files automatically before reading the config, so `process.env` references work without extra setup. The config file is resolved by walking up from the current working directory. -Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db setup`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. +Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. `db install` will scaffold `stash.config.ts` for you if it's missing. --- @@ -79,7 +79,7 @@ npx @cipherstash/cli init [--supabase] [--drizzle] | `--supabase` | Use the Supabase-specific setup flow | | `--drizzle` | Use the Drizzle-specific setup flow | -After `init` completes, the Next Steps output tells you to run `npx @cipherstash/cli db setup`, then either `npx @cipherstash/cli wizard` or edit the schema manually. +After `init` completes, the Next Steps output tells you to run `npx @cipherstash/cli db install`, then either `npx @cipherstash/cli wizard` or edit the schema manually. --- @@ -105,7 +105,7 @@ npx @cipherstash/cli wizard Prerequisites: - Authenticated (`npx @cipherstash/cli auth login` completed). -- `stash.config.ts` present (run `npx @cipherstash/cli db setup` first). +- `stash.config.ts` present (run `npx @cipherstash/cli db install` first; it will scaffold the config if missing). Supported integrations: Drizzle ORM, Supabase JS Client, Prisma (experimental), raw SQL / other. @@ -150,37 +150,11 @@ npx @cipherstash/cli secrets delete -n DATABASE_URL -e production -y --- -### `npx @cipherstash/cli db setup` - -Configure your database and install EQL extensions. Run this after `npx @cipherstash/cli init`. - -```bash -npx @cipherstash/cli db setup [options] -``` - -The interactive wizard: -1. Auto-detects your encryption client file (or asks for the path). -2. Prompts for a database URL (pre-fills from `DATABASE_URL`). -3. Writes `stash.config.ts`. -4. Asks which PostgreSQL provider you use to pick the right install flags. -5. Installs EQL extensions. - -| Flag | Description | -|------|-------------| -| `--force` | Overwrite existing `stash.config.ts` and reinstall EQL | -| `--dry-run` | Show what would happen without making changes | -| `--supabase` | Skip provider selection and use Supabase-compatible install | -| `--drizzle` | Generate a Drizzle migration instead of direct install | -| `--exclude-operator-family` | Skip operator family creation | -| `--latest` | Fetch the latest EQL from GitHub instead of the bundled version | -| `--name ` | Migration name (Drizzle mode, default: `install-eql`) | -| `--out ` | Drizzle output directory (default: `drizzle`) | - ---- - ### `npx @cipherstash/cli db install` -Install CipherStash EQL extensions into your database. Uses bundled SQL by default for offline, deterministic installs. +Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx @cipherstash/cli init`. + +When `stash.config.ts` is missing, the command auto-detects your encryption client file (or asks for the path) and writes the config before installing. Supabase and Drizzle are detected from your `DATABASE_URL` and project files, so the matching flags default on. Install uses bundled SQL for offline, deterministic runs. ```bash npx @cipherstash/cli db install [options] @@ -320,7 +294,7 @@ Reads `databaseUrl` from `stash.config.ts`. ## Drizzle migration mode -Use `--drizzle` with `npx @cipherstash/cli db install` (or `npx @cipherstash/cli db setup`) to add EQL installation to your Drizzle migration history instead of applying it directly. +Use `--drizzle` with `npx @cipherstash/cli db install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly. ```bash npx @cipherstash/cli db install --drizzle diff --git a/packages/cli/src/__tests__/detect.test.ts b/packages/cli/src/__tests__/detect.test.ts new file mode 100644 index 00000000..35d43358 --- /dev/null +++ b/packages/cli/src/__tests__/detect.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { detectDrizzle, detectSupabase } from '../commands/db/detect.js' + +describe('detectSupabase', () => { + it.each([ + ['postgres://user:pass@db.abc.supabase.co:5432/postgres', true], + ['postgres://user:pass@db.abc.supabase.com:5432/postgres', true], + [ + 'postgres://user:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres', + true, + ], + ['postgres://user:pass@localhost:5432/postgres', false], + ['postgres://user:pass@db.neon.tech:5432/neondb', false], + ['postgres://user:pass@ondemand.aws.neon.tech/neondb', false], + ])('returns %s for %s', (url, expected) => { + expect(detectSupabase(url)).toBe(expected) + }) + + it('returns false on undefined or malformed URLs', () => { + expect(detectSupabase(undefined)).toBe(false) + expect(detectSupabase('not a url')).toBe(false) + expect(detectSupabase('')).toBe(false) + }) +}) + +describe('detectDrizzle', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-detect-drizzle-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('returns true when a drizzle.config.ts exists', () => { + fs.writeFileSync( + path.join(tmpDir, 'drizzle.config.ts'), + 'export default {}', + ) + expect(detectDrizzle(tmpDir)).toBe(true) + }) + + it('returns true for drizzle.config.js', () => { + fs.writeFileSync( + path.join(tmpDir, 'drizzle.config.js'), + 'module.exports = {}', + ) + expect(detectDrizzle(tmpDir)).toBe(true) + }) + + it('returns true when package.json lists drizzle-orm', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: { 'drizzle-orm': '^0.40.0' } }), + ) + expect(detectDrizzle(tmpDir)).toBe(true) + }) + + it('returns true when package.json lists drizzle-kit as devDep', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ devDependencies: { 'drizzle-kit': '^0.30.0' } }), + ) + expect(detectDrizzle(tmpDir)).toBe(true) + }) + + it('returns false when nothing indicates drizzle', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: { prisma: '^5.0.0' } }), + ) + expect(detectDrizzle(tmpDir)).toBe(false) + }) + + it('returns false on an empty directory', () => { + expect(detectDrizzle(tmpDir)).toBe(false) + }) + + it('tolerates an unreadable package.json', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{ not valid json') + expect(detectDrizzle(tmpDir)).toBe(false) + }) +}) diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts new file mode 100644 index 00000000..4752d358 --- /dev/null +++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rewriteEncryptedAlterColumns } from '../commands/db/rewrite-migrations.js' + +describe('rewriteEncryptedAlterColumns', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-rewrite-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('rewrites an in-place ALTER COLUMN with the bare type name', async () => { + const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n` + const filePath = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(filePath, original) + + const rewritten = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" DROP COLUMN "amount";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" RENAME COLUMN "amount__cipherstash_tmp" TO "amount";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the schema-qualified form produced by drizzle-kit', async () => { + const original = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v2_encrypted";\n' + const filePath = path.join(tmpDir, '0003_alter.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('leaves unrelated migrations untouched', async () => { + const original = + 'CREATE TABLE "widgets" ("id" integer PRIMARY KEY, "name" text);\n' + const filePath = path.join(tmpDir, '0001_init.sql') + fs.writeFileSync(filePath, original) + + const rewritten = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('skips the file passed in options.skip', async () => { + const install = path.join(tmpDir, '0000_install-eql.sql') + const alter = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n') + fs.writeFileSync( + alter, + 'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v2_encrypted;', + ) + + const rewritten = await rewriteEncryptedAlterColumns(tmpDir, { + skip: install, + }) + expect(rewritten).toEqual([alter]) + expect(fs.readFileSync(install, 'utf-8')).toBe('CREATE SCHEMA eql_v2;\n') + }) + + it('returns an empty list when the directory does not exist', async () => { + const missing = path.join(tmpDir, 'does-not-exist') + const rewritten = await rewriteEncryptedAlterColumns(missing) + expect(rewritten).toEqual([]) + }) + + it('handles multiple ALTER statements in one file', async () => { + const original = [ + 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;', + 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v2_encrypted;', + 'CREATE INDEX "a_z" ON "a" ("z");', + ].join('\n') + const filePath = path.join(tmpDir, '0004_multi.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated.match(/ADD COLUMN/g)?.length).toBe(2) + expect(updated.match(/DROP COLUMN/g)?.length).toBe(2) + // Non-matching statement preserved + expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");') + }) +}) diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index c1b598c8..6212d79e 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -1,5 +1,13 @@ import { config } from 'dotenv' -config() + +// Load env files in Next.js precedence order. dotenv's default behavior is to +// not overwrite vars that are already set, so loading .env.local first means +// its values win over .env for the same keys. Users can still set anything in +// the real environment to override both. +config({ path: '.env.local' }) +config({ path: '.env.development.local' }) +config({ path: '.env.development' }) +config({ path: '.env' }) import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -8,9 +16,9 @@ import * as p from '@clack/prompts' // Commands that depend on @cipherstash/stack are lazy-loaded in the switch below. import { authCommand, + envCommand, initCommand, installCommand, - setupCommand, statusCommand, testConnectionCommand, upgradeCommand, @@ -31,8 +39,8 @@ async function requireStack(importFn: () => Promise): Promise { if (isModuleNotFound(err)) { p.log.error( '@cipherstash/stack is required for this command.\n' + - ' Install it with: npm install @cipherstash/stack\n' + - ' Or run: npx @cipherstash/cli init', + ' Install it with: npm install @cipherstash/stack\n' + + ' Or run: npx @cipherstash/cli init', ) process.exit(1) as never } @@ -55,9 +63,8 @@ Commands: auth Authenticate with CipherStash wizard AI-powered encryption setup (reads your codebase) - db install Install EQL extensions into your database + db install Scaffold stash.config.ts (if missing) and install EQL extensions db upgrade Upgrade EQL extensions to the latest version - db setup Configure database and install EQL extensions db push Push encryption schema to database (CipherStash Proxy only) db validate Validate encryption schema db migrate Run pending encrypt config migrations @@ -66,6 +73,8 @@ Commands: schema build Build an encryption schema from your database + env (experimental) Print production env vars for deployment + Options: --help, -h Show help --version, -v Show version @@ -75,19 +84,19 @@ Init Flags: --drizzle Use Drizzle-specific setup flow DB Flags: - --force (setup, install) Reinstall even if already installed - --dry-run (setup, install, push, upgrade) Show what would happen without making changes - --supabase (setup, install, upgrade, validate) Use Supabase-compatible mode - --drizzle (setup, install) Generate a Drizzle migration instead of direct install - --exclude-operator-family (setup, install, upgrade, validate) Skip operator family creation - --latest (setup, install, upgrade) Fetch the latest EQL from GitHub + --force (install) Reinstall 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) + --exclude-operator-family (install, upgrade, validate) Skip operator family creation + --latest (install, upgrade) Fetch the latest EQL from GitHub Examples: npx @cipherstash/cli init npx @cipherstash/cli init --supabase npx @cipherstash/cli auth login npx @cipherstash/cli wizard - npx @cipherstash/cli db setup + npx @cipherstash/cli db install npx @cipherstash/cli db push npx @cipherstash/cli schema build `.trim() @@ -155,25 +164,17 @@ async function runDbCommand( latest: flags.latest, }) break - case 'setup': - await setupCommand({ - force: flags.force, - dryRun: flags['dry-run'], - supabase: flags.supabase, - excludeOperatorFamily: flags['exclude-operator-family'], - drizzle: flags.drizzle, - latest: flags.latest, - name: values.name, - out: values.out, - }) - break case 'push': { - const { pushCommand } = await requireStack(() => import('../commands/db/push.js')) + const { pushCommand } = await requireStack( + () => import('../commands/db/push.js'), + ) await pushCommand({ dryRun: flags['dry-run'] }) break } case 'validate': { - const { validateCommand } = await requireStack(() => import('../commands/db/validate.js')) + const { validateCommand } = await requireStack( + () => import('../commands/db/validate.js'), + ) await validateCommand({ supabase: flags.supabase, excludeOperatorFamily: flags['exclude-operator-family'], @@ -203,7 +204,9 @@ async function runSchemaCommand( ) { switch (sub) { case 'build': { - const { builderCommand } = await requireStack(() => import('../commands/schema/build.js')) + const { builderCommand } = await requireStack( + () => import('../commands/schema/build.js'), + ) await builderCommand({ supabase: flags.supabase }) break } @@ -255,6 +258,9 @@ async function main() { case 'schema': await runSchemaCommand(subcommand, flags) break + case 'env': + await envCommand({ write: flags.write }) + break default: console.error(`Unknown command: ${command}\n`) console.log(HELP) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 9aa7229b..50189d99 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,7 +1,15 @@ -import * as p from '@clack/prompts' import auth from '@cipherstash/auth' +import * as p from '@clack/prompts' const { beginDeviceCodeFlow, bindClientDevice } = auth +// TODO(CIP-2996): @cipherstash/auth@0.35.0 (latest on npm as of 2026-04-17) +// writes the device-code token to a profile dir that later auth reads do not +// find, so subsequent CLI invocations fail to resolve credentials. The fix is +// upstream — bump this catalog pin once a newer @cipherstash/auth that +// aligns the write + read paths ships. Do not paper over it in the CLI: +// divergent profile-dir logic across tools is exactly what caused the +// regression in the first place. + // TODO: pull from the CTS API export const regions = [ { value: 'us-east-1.aws', label: 'us-east-1 (Virginia, USA)' }, diff --git a/packages/cli/src/commands/db/config-scaffold.ts b/packages/cli/src/commands/db/config-scaffold.ts new file mode 100644 index 00000000..fac79e78 --- /dev/null +++ b/packages/cli/src/commands/db/config-scaffold.ts @@ -0,0 +1,106 @@ +import { existsSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' + +export const CONFIG_FILENAME = 'stash.config.ts' + +/** + * Common locations where an encryption client file might live. Checked in + * order of priority during auto-detection. + */ +const COMMON_CLIENT_PATHS = [ + './src/encryption/index.ts', + './src/encryption.ts', + './encryption/index.ts', + './encryption.ts', + './src/lib/encryption/index.ts', + './src/lib/encryption.ts', +] as const + +/** + * Scan the project for an existing encryption client file at a common + * location. Returns the first match, or `undefined`. + */ +export function detectClientPath( + cwd: string = process.cwd(), +): string | undefined { + for (const candidate of COMMON_CLIENT_PATHS) { + if (existsSync(resolve(cwd, candidate))) return candidate + } + return undefined +} + +/** + * Prompt the user to confirm a detected client path, or enter one manually. + * Returns the confirmed path, or `undefined` if the user cancels. + */ +export async function resolveClientPath( + cwd: string = process.cwd(), +): Promise { + const detected = detectClientPath(cwd) + + if (detected) { + const useDetected = await p.confirm({ + message: `Found encryption client at ${detected}. Use this path?`, + initialValue: true, + }) + + if (p.isCancel(useDetected)) return undefined + if (useDetected) return detected + } + + const clientPath = await p.text({ + message: 'Where is your encryption client file?', + placeholder: './src/encryption/index.ts', + defaultValue: './src/encryption/index.ts', + initialValue: detected ?? './src/encryption/index.ts', + validate(value) { + if (!value || value.trim().length === 0) { + return 'Client file path is required.' + } + if (!value.endsWith('.ts')) { + return 'Client file path must end with .ts' + } + }, + }) + + if (p.isCancel(clientPath)) return undefined + return clientPath +} + +function generateConfig(clientPath: string): string { + return `import { defineConfig } from '@cipherstash/cli' + +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, + client: '${clientPath}', +}) +` +} + +/** + * Create a `stash.config.ts` at the project root if one doesn't already exist. + * Returns `true` if a config is present (either pre-existing or freshly + * written), `false` if the user cancelled the prompt. + * + * Invoked by `db install` when no `stash.config.ts` exists, so users don't + * need to run a separate `setup` step before installing EQL. + */ +export async function ensureStashConfig( + cwd: string = process.cwd(), +): Promise { + const configPath = resolve(cwd, CONFIG_FILENAME) + if (existsSync(configPath)) return true + + p.log.info(`No ${CONFIG_FILENAME} found — let's create one.`) + + const clientPath = await resolveClientPath(cwd) + if (!clientPath) { + p.cancel('Setup cancelled.') + return false + } + + writeFileSync(configPath, generateConfig(clientPath), 'utf-8') + p.log.success(`Created ${CONFIG_FILENAME}`) + return true +} diff --git a/packages/cli/src/commands/db/detect.ts b/packages/cli/src/commands/db/detect.ts new file mode 100644 index 00000000..d2618df4 --- /dev/null +++ b/packages/cli/src/commands/db/detect.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * Return true when the connection string points at a Supabase-hosted Postgres. + * + * Supabase routes production connections through `*.supabase.co`, + * `*.supabase.com`, and the pgBouncer pooler at `*.pooler.supabase.com`. + * Matching on host means we auto-detect regardless of direct vs pooled + * connection string. + */ +export function detectSupabase(databaseUrl: string | undefined): boolean { + if (!databaseUrl) return false + + let host: string + try { + host = new URL(databaseUrl).hostname + } catch { + return false + } + + return ( + host.endsWith('.supabase.co') || + host.endsWith('.supabase.com') || + host.endsWith('.pooler.supabase.com') + ) +} + +/** + * Return true when the project uses Drizzle. + * + * We look for a `drizzle.config.*` file at the cwd (fast path) or + * `drizzle-orm` / `drizzle-kit` listed in the project's package.json. + * Either signal alone is enough — Drizzle users always have at least one. + */ +export function detectDrizzle(cwd: string): boolean { + const configCandidates = [ + 'drizzle.config.ts', + 'drizzle.config.js', + 'drizzle.config.mjs', + 'drizzle.config.cjs', + ] + for (const candidate of configCandidates) { + if (existsSync(resolve(cwd, candidate))) return true + } + + const pkgPath = resolve(cwd, 'package.json') + if (!existsSync(pkgPath)) return false + + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + } + const deps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + ...pkg.optionalDependencies, + } + return 'drizzle-orm' in deps || 'drizzle-kit' in deps + } catch { + return false + } +} diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 64b56179..a1cbd9ab 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -5,38 +5,61 @@ import { join, resolve } from 'node:path' import { loadStashConfig } from '@/config/index.js' import { EQLInstaller, - loadBundledEqlSql, downloadEqlSql, + loadBundledEqlSql, } from '@/installer/index.js' import * as p from '@clack/prompts' +import { ensureStashConfig } from './config-scaffold.js' +import { detectDrizzle, detectSupabase } from './detect.js' +import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' const DEFAULT_MIGRATION_NAME = 'install-eql' const DEFAULT_DRIZZLE_OUT = 'drizzle' -export async function installCommand(options: { +export interface InstallOptions { force?: boolean dryRun?: boolean + /** + * `undefined` means "auto-detect" (via {@link detectSupabase}). An explicit + * `true`/`false` from the user is preserved and skips detection. + */ excludeOperatorFamily?: boolean supabase?: boolean drizzle?: boolean latest?: boolean name?: string out?: string -}) { +} + +export async function installCommand(options: InstallOptions) { p.intro('npx @cipherstash/cli db install') + // 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). + const configReady = await ensureStashConfig() + if (!configReady) { + process.exit(0) + } + const s = p.spinner() s.start('Loading stash.config.ts...') const config = await loadStashConfig() s.stop('Configuration loaded.') - if (options.drizzle) { + // Auto-detect provider hints when the user didn't explicitly pass flags. + // CIP-2985. + const resolved = resolveProviderOptions(options, config.databaseUrl) + + if (resolved.drizzle) { await generateDrizzleMigration(s, { name: options.name, out: options.out, dryRun: options.dryRun, latest: options.latest, + supabase: resolved.supabase, + excludeOperatorFamily: resolved.excludeOperatorFamily, }) return } @@ -46,10 +69,7 @@ export async function installCommand(options: { const source = options.latest ? 'Would download EQL install script from GitHub' : 'Would use bundled EQL install script' - p.note( - `${source}\nWould execute the SQL against the database`, - 'Dry Run', - ) + p.note(`${source}\nWould execute the SQL against the database`, 'Dry Run') p.outro('Dry run complete.') return } @@ -61,7 +81,22 @@ export async function installCommand(options: { s.start('Checking database permissions...') const permissions = await installer.checkPermissions() - if (!permissions.ok) { + // CIP-2989: if the role is not a superuser and neither --supabase nor + // --exclude-operator-family was passed, auto-fall back to the + // no-operator-family (OPE) install variant. This is the same thing an + // experienced user would do manually; doing it automatically avoids the + // "what flag do I need?" failure mode on Supabase/Neon/RDS. + let excludeOperatorFamily = resolved.excludeOperatorFamily + if ( + !permissions.isSuperuser && + !resolved.supabase && + options.excludeOperatorFamily === undefined + ) { + excludeOperatorFamily = true + s.stop( + 'Role lacks superuser — falling back to the no-operator-family (OPE) install.', + ) + } else if (!permissions.ok) { s.stop('Insufficient database permissions.') p.log.error('The connected database role is missing required permissions:') for (const missing of permissions.missing) { @@ -73,8 +108,9 @@ export async function installCommand(options: { ) p.outro('Installation aborted.') process.exit(1) + } else { + s.stop('Database permissions verified.') } - s.stop('Database permissions verified.') if (!options.force) { s.start('Checking if EQL is already installed...') @@ -91,19 +127,80 @@ export async function installCommand(options: { const source = options.latest ? 'from GitHub (latest)' : 'bundled' s.start(`Installing EQL extensions (${source})...`) await installer.install({ - excludeOperatorFamily: options.excludeOperatorFamily, - supabase: options.supabase, + excludeOperatorFamily, + supabase: resolved.supabase, latest: options.latest, }) s.stop('EQL extensions installed.') - if (options.supabase) { + if (resolved.supabase) { p.log.success('Supabase role permissions granted.') } + printNextSteps() p.outro('Done!') } +/** + * Merge explicit CLI flags with auto-detected hints. + * + * Rules: + * - `--supabase` explicitly passed wins. + * - `--supabase` not passed → if the database URL looks like Supabase, enable it. + * - `--drizzle` explicitly passed wins. + * - `--drizzle` not passed → if drizzle-orm/drizzle-kit/drizzle.config.* exists, enable it. + * - `--exclude-operator-family` explicitly passed wins. + */ +function resolveProviderOptions( + options: InstallOptions, + databaseUrl: string, +): { + supabase: boolean + drizzle: boolean + excludeOperatorFamily: boolean +} { + const supabase = + options.supabase === undefined + ? detectSupabase(databaseUrl) + : options.supabase + if (options.supabase === undefined && supabase) { + p.log.info( + 'Detected Supabase database from DATABASE_URL — enabling --supabase.', + ) + } + + const drizzle = + options.drizzle === undefined + ? detectDrizzle(process.cwd()) + : options.drizzle + if (options.drizzle === undefined && drizzle) { + p.log.info('Detected Drizzle in this project — enabling --drizzle.') + } + + const excludeOperatorFamily = options.excludeOperatorFamily ?? false + + return { supabase, drizzle, excludeOperatorFamily } +} + +function printNextSteps(): void { + p.note( + [ + 'Next steps:', + '', + ' 1. Wire up encrypt/decrypt with the wizard:', + ' npx @cipherstash/cli wizard', + '', + ' 2. Or use the client directly from @cipherstash/stack:', + " import { Encryption } from '@cipherstash/stack'", + ' const client = await Encryption({ /* ... */ })', + ' await client.encryptModel(record, table).run()', + '', + ' 3. Docs: https://cipherstash.com/docs', + ].join('\n'), + 'What next', + ) +} + /** * Generate a Drizzle migration that installs CipherStash EQL. * @@ -113,7 +210,14 @@ export async function installCommand(options: { */ async function generateDrizzleMigration( s: ReturnType, - options: { name?: string; out?: string; dryRun?: boolean; latest?: boolean }, + options: { + name?: string + out?: string + dryRun?: boolean + latest?: boolean + supabase?: boolean + excludeOperatorFamily?: boolean + }, ) { const migrationName = options.name ?? DEFAULT_MIGRATION_NAME const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT) @@ -171,26 +275,28 @@ async function generateDrizzleMigration( s.stop(`Found migration: ${generatedMigrationPath}`) } catch (error) { s.stop('Failed to locate migration file.') - p.log.error( - error instanceof Error ? error.message : String(error), - ) + p.log.error(error instanceof Error ? error.message : String(error)) p.outro('Migration aborted.') process.exit(1) } - // Step 3: Load the EQL SQL (bundled or from GitHub) + // Step 3: Load the EQL SQL (bundled or from GitHub). Thread supabase / + // excludeOperatorFamily through so the user's flag reaches the SQL + // selection — previously this path ignored both (CIP-2988). let eqlSql: string + const sqlOptions = { + supabase: options.supabase ?? false, + excludeOperatorFamily: options.excludeOperatorFamily ?? false, + } if (options.latest) { s.start('Downloading EQL install script from GitHub (latest)...') try { - eqlSql = await downloadEqlSql() + eqlSql = await downloadEqlSql(sqlOptions) s.stop('EQL install script downloaded.') } catch (error) { s.stop('Failed to download EQL install script.') - p.log.error( - error instanceof Error ? error.message : String(error), - ) + p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') process.exit(1) @@ -198,13 +304,11 @@ async function generateDrizzleMigration( } else { s.start('Loading bundled EQL install script...') try { - eqlSql = loadBundledEqlSql() + eqlSql = loadBundledEqlSql(sqlOptions) s.stop('Bundled EQL install script loaded.') } catch (error) { s.stop('Failed to load bundled EQL install script.') - p.log.error( - error instanceof Error ? error.message : String(error), - ) + p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') process.exit(1) @@ -219,19 +323,39 @@ async function generateDrizzleMigration( s.stop('EQL SQL written to migration file.') } catch (error) { s.stop('Failed to write migration file.') - p.log.error( - error instanceof Error ? error.message : String(error), - ) + p.log.error(error instanceof Error ? error.message : String(error)) cleanupMigrationFile(generatedMigrationPath) p.outro('Migration aborted.') process.exit(1) } + // Step 5: Sweep for sibling migrations that drizzle-kit may have emitted + // with `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted`. These fail in + // Postgres because there's no implicit cast from text/numeric to the + // encrypted type. Rewrite them into the ADD/UPDATE/DROP/RENAME sequence + // that works on both empty and populated tables. CIP-2991 + CIP-2994. + try { + const rewritten = await rewriteEncryptedAlterColumns(outDir, { + skip: generatedMigrationPath, + }) + if (rewritten.length > 0) { + p.log.info( + `Rewrote ${rewritten.length} migration file(s) to use safe ADD+migrate+DROP for encrypted columns:`, + ) + for (const file of rewritten) p.log.step(` - ${file}`) + } + } catch (error) { + p.log.warn( + `Could not rewrite ALTER COLUMN migrations: ${error instanceof Error ? error.message : String(error)}`, + ) + } + p.log.success(`Migration created: ${generatedMigrationPath}`) p.note( 'Run your Drizzle migrations to install EQL:\n\n npx drizzle-kit migrate', 'Next Steps', ) + printNextSteps() p.outro('Done!') } @@ -252,9 +376,7 @@ async function findGeneratedMigration( const entries = await readdir(outDir) const matchingFiles = entries - .filter( - (entry) => entry.endsWith('.sql') && entry.includes(migrationName), - ) + .filter((entry) => entry.endsWith('.sql') && entry.includes(migrationName)) .sort() if (matchingFiles.length === 0) { diff --git a/packages/cli/src/commands/db/rewrite-migrations.ts b/packages/cli/src/commands/db/rewrite-migrations.ts new file mode 100644 index 00000000..55fa7611 --- /dev/null +++ b/packages/cli/src/commands/db/rewrite-migrations.ts @@ -0,0 +1,79 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * Matches drizzle-kit's generated in-place type change to the encrypted + * column type. We accept both the fully-qualified + * `"public"."eql_v2_encrypted"` form (emitted after CIP-2990) and the bare + * `eql_v2_encrypted` form older schemas produced. + * + * Captures: + * - $1: table name (without quotes) + * - $2: column name (without quotes) + */ +const ALTER_COLUMN_TO_ENCRYPTED_RE = + /ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"public"\."eql_v2_encrypted"|eql_v2_encrypted)[^;]*;/gi + +/** + * Replace in-place `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` statements + * with an ADD + DROP + RENAME sequence. + * + * **Why this exists (CIP-2991, CIP-2994):** Postgres has no implicit cast from + * `text`/`numeric` to `eql_v2_encrypted`, so `ALTER COLUMN ... SET DATA TYPE + * eql_v2_encrypted` fails with `cannot cast type ... to eql_v2_encrypted`. + * The fix that works on both empty and non-empty tables is to add a new + * encrypted column, backfill it, drop the original, and rename the new + * column into place. For empty tables the UPDATE is a no-op and the + * sequence is effectively equivalent to DROP+ADD. + * + * We only rewrite the statement — the actual encryption of existing rows has + * to happen in application code (via `encryptModel` from + * `@cipherstash/stack`), which is why the UPDATE is emitted as a guidance + * comment rather than real SQL. Running this migration against a populated + * table leaves the new column NULL until the app backfills it. + */ +export async function rewriteEncryptedAlterColumns( + outDir: string, + options: { skip?: string } = {}, +): Promise { + const entries = await readdir(outDir).catch(() => []) + const rewritten: string[] = [] + + for (const entry of entries) { + if (!entry.endsWith('.sql')) continue + const filePath = join(outDir, entry) + if (options.skip && filePath === options.skip) continue + + const original = await readFile(filePath, 'utf-8') + if (!ALTER_COLUMN_TO_ENCRYPTED_RE.test(original)) continue + + // Reset the regex's lastIndex — it's stateful on /g + ALTER_COLUMN_TO_ENCRYPTED_RE.lastIndex = 0 + + const updated = original.replace( + ALTER_COLUMN_TO_ENCRYPTED_RE, + (_match, table: string, column: string) => renderSafeAlter(table, column), + ) + + if (updated !== original) { + await writeFile(filePath, updated, 'utf-8') + rewritten.push(filePath) + } + } + + return rewritten +} + +function renderSafeAlter(table: string, column: string): string { + const tmp = `${column}__cipherstash_tmp` + return [ + '-- Rewritten by @cipherstash/cli: in-place ALTER COLUMN cannot cast to', + `-- eql_v2_encrypted. If "${table}" already has rows, backfill the new`, + "-- column via @cipherstash/stack's encryptModel in application code BEFORE", + '-- running this migration in production. Empty tables are safe as-is.', + `ALTER TABLE "${table}" ADD COLUMN "${tmp}" "public"."eql_v2_encrypted";`, + `-- UPDATE "${table}" SET "${tmp}" = /* encrypted value for ${column} */ NULL;`, + `ALTER TABLE "${table}" DROP COLUMN "${column}";`, + `ALTER TABLE "${table}" RENAME COLUMN "${tmp}" TO "${column}";`, + ].join('\n') +} diff --git a/packages/cli/src/commands/db/setup.ts b/packages/cli/src/commands/db/setup.ts deleted file mode 100644 index 0aaf09a5..00000000 --- a/packages/cli/src/commands/db/setup.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { existsSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import * as p from '@clack/prompts' -import { installCommand } from './install.js' - -const CONFIG_FILENAME = 'stash.config.ts' - -/** - * Common locations where an encryption client file might live. - * Checked in order of priority during auto-detection. - */ -const COMMON_CLIENT_PATHS = [ - './src/encryption/index.ts', - './src/encryption.ts', - './encryption/index.ts', - './encryption.ts', - './src/lib/encryption/index.ts', - './src/lib/encryption.ts', -] as const - -export interface SetupOptions { - force?: boolean - dryRun?: boolean - supabase?: boolean - excludeOperatorFamily?: boolean - drizzle?: boolean - latest?: boolean - name?: string - out?: string -} - -/** - * Scans the project for an existing encryption client file at common locations. - * Returns the first match, or `undefined` if none found. - */ -function detectClientPath(): string | undefined { - const cwd = process.cwd() - for (const candidate of COMMON_CLIENT_PATHS) { - if (existsSync(resolve(cwd, candidate))) { - return candidate - } - } - return undefined -} - -/** - * Prompts the user to confirm a detected client path or enter one manually. - * Returns the confirmed path, or `undefined` if the user cancels. - */ -async function resolveClientPath(): Promise { - const detected = detectClientPath() - - if (detected) { - const useDetected = await p.confirm({ - message: `Found encryption client at ${detected}. Use this path?`, - initialValue: true, - }) - - if (p.isCancel(useDetected)) return undefined - if (useDetected) return detected - } - - const clientPath = await p.text({ - message: 'Where is your encryption client file?', - placeholder: './src/encryption/index.ts', - defaultValue: './src/encryption/index.ts', - initialValue: detected ?? './src/encryption/index.ts', - validate(value) { - if (!value || value.trim().length === 0) { - return 'Client file path is required.' - } - if (!value.endsWith('.ts')) { - return 'Client file path must end with .ts' - } - }, - }) - - if (p.isCancel(clientPath)) return undefined - return clientPath -} - -function generateConfig(clientPath: string): string { - return `import { defineConfig } from '@cipherstash/cli' - -export default defineConfig({ - databaseUrl: process.env.DATABASE_URL!, - client: '${clientPath}', -}) -` -} - -export async function setupCommand(options: SetupOptions = {}) { - p.intro('npx @cipherstash/cli db setup') - - // 1. Check if stash.config.ts already exists - const configPath = resolve(process.cwd(), CONFIG_FILENAME) - if (existsSync(configPath) && !options.force) { - p.log.warn(`${CONFIG_FILENAME} already exists. Skipping setup.`) - p.log.info( - `Use --force to overwrite, or delete ${CONFIG_FILENAME} and re-run "npx @cipherstash/cli db setup".`, - ) - p.outro('Nothing to do.') - return - } - - // 2. Auto-detect encryption client file path - const clientPath = await resolveClientPath() - if (!clientPath) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - // 3. Generate stash.config.ts - const configContent = generateConfig(clientPath) - writeFileSync(configPath, configContent, 'utf-8') - p.log.success(`Created ${CONFIG_FILENAME}`) - - // 4. Install EQL extensions (only if DATABASE_URL is available) - if (!process.env.DATABASE_URL) { - p.note( - 'Set DATABASE_URL in your environment, then run:\n npx @cipherstash/cli db install', - 'DATABASE_URL not set', - ) - p.outro('CipherStash Forge setup complete!') - return - } - - const shouldInstall = await p.confirm({ - message: 'Install EQL extensions in your database now?', - initialValue: true, - }) - - if (p.isCancel(shouldInstall)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - if (!shouldInstall) { - p.note( - 'You can install EQL later:\n npx @cipherstash/cli db install', - 'Skipped Installation', - ) - p.outro('CipherStash Forge setup complete!') - return - } - - // 6. Determine install flags from database provider - const installOptions = await resolveInstallOptions(options) - - await installCommand({ - ...installOptions, - drizzle: options.drizzle, - latest: options.latest, - name: options.name, - out: options.out, - }) -} - -type DatabaseProvider = - | 'supabase' - | 'neon' - | 'vercel-postgres' - | 'aws-rds' - | 'planetscale' - | 'prisma-postgres' - | 'other' - -/** - * Resolves install flags based on the user's database provider. - * Skips the prompt if `--supabase` was already passed as a CLI flag. - */ -async function resolveInstallOptions( - options: SetupOptions, -): Promise> { - // If --supabase was already passed, skip the prompt - if (options.supabase) { - return { - force: options.force, - dryRun: options.dryRun, - supabase: true, - } - } - - const provider = await p.select({ - message: 'What Postgres database are you using?', - options: [ - { value: 'supabase', label: 'Supabase' }, - { value: 'neon', label: 'Neon' }, - { value: 'vercel-postgres', label: 'Vercel Postgres' }, - { value: 'aws-rds', label: 'AWS RDS' }, - { value: 'planetscale', label: 'PlanetScale' }, - { value: 'prisma-postgres', label: 'Prisma Postgres' }, - { value: 'other', label: 'Other / Self-hosted' }, - ], - }) - - if (p.isCancel(provider)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - switch (provider) { - case 'supabase': - return { - force: options.force, - dryRun: options.dryRun, - supabase: true, - } - case 'neon': - case 'vercel-postgres': - case 'planetscale': - case 'prisma-postgres': - return { - force: options.force, - dryRun: options.dryRun, - excludeOperatorFamily: true, - } - default: - return { - force: options.force, - dryRun: options.dryRun, - } - } -} \ No newline at end of file diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts new file mode 100644 index 00000000..1dab4983 --- /dev/null +++ b/packages/cli/src/commands/env/index.ts @@ -0,0 +1,91 @@ +import { existsSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' + +export interface EnvOptions { + /** Write the emitted block to `.env.production.local` instead of stdout. */ + write?: boolean +} + +/** + * Generate production env vars for CipherStash using the local device-code + * auth. Today this is a scaffold (CIP-2997): the CTS endpoint that mints a + * production access key isn't wired up yet, so the command is gated behind + * the `STASH_EXPERIMENTAL_ENV_CMD` env flag to keep it out of the way until + * the backend piece lands. + * + * Once CTS exposes the mint endpoint, fill in `fetchProdCredentials` below. + * The emitted format matches what apps in the onboarding flow consume via + * `process.env.*`, so `--write` produces a file the user can commit into a + * deployment secrets store. + */ +export async function envCommand(options: EnvOptions = {}): Promise { + if (!process.env.STASH_EXPERIMENTAL_ENV_CMD) { + p.log.warn('`env` is experimental and not ready for production use yet.') + p.log.info( + 'Set STASH_EXPERIMENTAL_ENV_CMD=1 in your environment to try it.', + ) + return + } + + p.intro('npx @cipherstash/cli env') + + const creds = await fetchProdCredentials() + if (!creds) { + p.log.error( + 'Could not mint production credentials. Make sure you are logged in: npx @cipherstash/cli auth login', + ) + process.exit(1) + } + + const block = formatEnvBlock(creds) + + if (options.write) { + const target = resolve(process.cwd(), '.env.production.local') + if (existsSync(target)) { + const overwrite = await p.confirm({ + message: `${target} already exists. Overwrite?`, + initialValue: false, + }) + if (p.isCancel(overwrite) || !overwrite) { + p.cancel('Aborted.') + return + } + } + + writeFileSync(target, block, 'utf-8') + p.log.success(`Wrote ${target}`) + p.outro('Done!') + return + } + + // Default: print to stdout so users can pipe into secret stores / CI env. + // Use `console.log` (not `p.*`) so the output is clean for redirection. + console.log(block) + p.outro('Done!') +} + +interface ProdCredentials { + clientId: string + clientKey: string + workspaceId: string +} + +async function fetchProdCredentials(): Promise { + // TODO(CIP-2997): call the CTS mint endpoint once it's available. The + // endpoint shape, auth header, and error codes are TBD — coordinate with + // the platform team before wiring this up. Until then, return undefined + // so the experimental command fails loudly rather than silently emitting + // placeholder credentials. + return undefined +} + +function formatEnvBlock(creds: ProdCredentials): string { + return [ + '# Generated by `npx @cipherstash/cli env` — production credentials', + `CS_CLIENT_ID=${creds.clientId}`, + `CS_CLIENT_KEY=${creds.clientKey}`, + `CS_WORKSPACE_ID=${creds.workspaceId}`, + '', + ].join('\n') +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a64b53ed..8b3e236b 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,7 +1,7 @@ -export { setupCommand } from './db/setup.js' export { installCommand } from './db/install.js' export { statusCommand } from './db/status.js' export { testConnectionCommand } from './db/test-connection.js' export { upgradeCommand } from './db/upgrade.js' export { authCommand } from './auth/index.js' export { initCommand } from './init/index.js' +export { envCommand } from './env/index.js' diff --git a/packages/cli/src/commands/init/providers/base.ts b/packages/cli/src/commands/init/providers/base.ts index e8237a64..ece73482 100644 --- a/packages/cli/src/commands/init/providers/base.ts +++ b/packages/cli/src/commands/init/providers/base.ts @@ -11,7 +11,7 @@ export function createBaseProvider(): InitProvider { { value: 'raw-sql', label: 'Raw SQL / pg' }, ], getNextSteps(state: InitState): string[] { - const steps = ['Set up your database: npx @cipherstash/cli db setup'] + const steps = ['Set up your database: npx @cipherstash/cli db install'] const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` diff --git a/packages/cli/src/commands/init/providers/drizzle.ts b/packages/cli/src/commands/init/providers/drizzle.ts index e14c6eed..65fde890 100644 --- a/packages/cli/src/commands/init/providers/drizzle.ts +++ b/packages/cli/src/commands/init/providers/drizzle.ts @@ -11,7 +11,7 @@ export function createDrizzleProvider(): InitProvider { { value: 'raw-sql', label: 'Raw SQL / pg' }, ], getNextSteps(state: InitState): string[] { - const steps = ['Set up your database: npx @cipherstash/cli db setup --drizzle'] + const steps = ['Set up your database: npx @cipherstash/cli db install --drizzle'] const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` diff --git a/packages/cli/src/commands/init/providers/supabase.ts b/packages/cli/src/commands/init/providers/supabase.ts index 0887d5ab..41019475 100644 --- a/packages/cli/src/commands/init/providers/supabase.ts +++ b/packages/cli/src/commands/init/providers/supabase.ts @@ -15,7 +15,7 @@ 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 setup --supabase'] + const steps = ['Set up your database: npx @cipherstash/cli db install --supabase'] const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` diff --git a/packages/cli/src/commands/init/steps/install-forge.ts b/packages/cli/src/commands/init/steps/install-forge.ts index 0e0f1f79..e1f3c442 100644 --- a/packages/cli/src/commands/init/steps/install-forge.ts +++ b/packages/cli/src/commands/init/steps/install-forge.ts @@ -18,7 +18,10 @@ const FORGE_PACKAGE = '@cipherstash/cli' */ async function installIfNeeded( packageName: string, - buildCommand: (pm: ReturnType, pkg: string) => string, + buildCommand: ( + pm: ReturnType, + pkg: string, + ) => string, depLabel: string, ): Promise { if (isPackageInstalled(packageName)) { @@ -44,16 +47,19 @@ async function installIfNeeded( return false } - const s = p.spinner() - s.start(`Installing ${packageName}...`) + // Stream npm/pnpm/yarn output directly so the user sees progress. Package + // installs can take tens of seconds and a silent spinner makes the CLI look + // hung. We log a "starting" line here and a success/failure line after, + // letting the package manager own the terminal in between. + p.log.step(`Running: ${cmd}`) try { - execSync(cmd, { cwd: process.cwd(), stdio: 'pipe' }) - s.stop(`${packageName} installed successfully`) + execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' }) + p.log.success(`${packageName} installed successfully`) return true } catch (err) { const message = err instanceof Error ? err.message : String(err) - s.stop(`${packageName} installation failed`) + p.log.error(`${packageName} installation failed`) p.log.error(message) p.note(`You can install it manually:\n ${cmd}`, 'Manual Installation') return false @@ -65,10 +71,18 @@ export const installForgeStep: InitStep = { name: 'Install stack dependencies', async run(state: InitState, _provider: InitProvider): Promise { // Install @cipherstash/stack as a production dependency - const stackInstalled = await installIfNeeded(STACK_PACKAGE, prodInstallCommand, 'production') + const stackInstalled = await installIfNeeded( + STACK_PACKAGE, + prodInstallCommand, + 'production', + ) // Install @cipherstash/cli as a dev dependency - const forgeInstalled = await installIfNeeded(FORGE_PACKAGE, devInstallCommand, 'dev') + const forgeInstalled = await installIfNeeded( + FORGE_PACKAGE, + devInstallCommand, + 'dev', + ) return { ...state, forgeInstalled, stackInstalled } }, diff --git a/packages/cli/src/commands/wizard/lib/changelog.ts b/packages/cli/src/commands/wizard/lib/changelog.ts new file mode 100644 index 00000000..e03c6fd2 --- /dev/null +++ b/packages/cli/src/commands/wizard/lib/changelog.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdirSync } from 'node:fs' +import { appendFile, readFile, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +/** + * Local markdown log the wizard writes to `/.cipherstash/wizard-log.md` + * after each run. Captures the sequence of decisions and file touches so + * future agents (and humans) have a record of what happened (CIP-2993). + * + * Appended to, never overwritten — each wizard invocation starts a fresh + * section delimited by a timestamp header. + */ +export class WizardChangelog { + private readonly entries: ChangelogEntry[] = [] + private readonly startedAt = new Date() + + constructor(private readonly cwd: string) {} + + record(entry: ChangelogEntry): void { + this.entries.push(entry) + } + + phase(name: string, detail?: string): void { + this.record({ kind: 'phase', name, detail }) + } + + action(description: string, files?: string[]): void { + this.record({ kind: 'action', description, files }) + } + + note(text: string): void { + this.record({ kind: 'note', text }) + } + + /** + * Serialize the collected entries and append to `/.cipherstash/wizard-log.md`. + * Safe to call multiple times — only appends new content. + */ + async flush(): Promise { + if (this.entries.length === 0) return undefined + + const dir = resolve(this.cwd, '.cipherstash') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const path = join(dir, 'wizard-log.md') + + const isNew = !existsSync(path) + const header = isNew ? '# CipherStash Wizard log\n\n' : '' + const body = this.render() + + if (isNew) { + await writeFile(path, header + body, 'utf-8') + } else { + await appendFile(path, body, 'utf-8') + } + + return path + } + + /** Inspect the most recent persisted log, for tests or debug. */ + async readExisting(): Promise { + const path = join(resolve(this.cwd, '.cipherstash'), 'wizard-log.md') + if (!existsSync(path)) return undefined + return readFile(path, 'utf-8') + } + + private render(): string { + const header = `## Run ${this.startedAt.toISOString()}\n\n` + const lines: string[] = [header] + for (const entry of this.entries) { + lines.push(renderEntry(entry)) + } + lines.push('') + return lines.join('\n') + } +} + +function renderEntry(entry: ChangelogEntry): string { + switch (entry.kind) { + case 'phase': + return entry.detail + ? `### ${entry.name}\n\n${entry.detail}\n` + : `### ${entry.name}\n` + case 'action': { + const filesBlock = entry.files?.length + ? `\n${entry.files.map((f) => ` - \`${f}\``).join('\n')}\n` + : '' + return `- ${entry.description}${filesBlock}` + } + case 'note': + return `> ${entry.text}\n` + } +} + +type ChangelogEntry = + | { kind: 'phase'; name: string; detail?: string } + | { kind: 'action'; description: string; files?: string[] } + | { kind: 'note'; text: string } diff --git a/packages/cli/src/commands/wizard/lib/install-skills.ts b/packages/cli/src/commands/wizard/lib/install-skills.ts new file mode 100644 index 00000000..463addb5 --- /dev/null +++ b/packages/cli/src/commands/wizard/lib/install-skills.ts @@ -0,0 +1,100 @@ +import { cpSync, existsSync, mkdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +import type { Integration } from './types.js' + +/** + * Which bundled skills are relevant for each wizard integration. These ship + * alongside the CLI (see `tsup.config.ts` — `skills/` is copied into + * `dist/skills/` at build time). The wizard offers to copy the matching + * skills into the user's project so Claude Code picks them up during + * follow-up work (CIP-2992). + */ +const SKILL_MAP: Record = { + drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'], + supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'], + prisma: ['stash-encryption', 'stash-cli'], + generic: ['stash-encryption', 'stash-cli'], +} + +/** + * Prompt the user, and if they say yes, copy the selected skills into + * `/.claude/skills//`. Returns the list of skill names + * actually copied (empty if declined or nothing to copy). + */ +export async function maybeInstallSkills( + cwd: string, + integration: Integration, +): Promise { + const skills = SKILL_MAP[integration] ?? SKILL_MAP.generic + const bundledRoot = resolveBundledSkillsRoot() + if (!bundledRoot) { + p.log.warn( + 'Skills bundle not found in this CLI build — skipping skills install.', + ) + return [] + } + + const available = skills.filter((name) => existsSync(join(bundledRoot, name))) + if (available.length === 0) return [] + + const confirmed = await p.confirm({ + message: `Install ${available.length} Claude skill(s) into ./.claude/skills/ (${available.join(', ')})?`, + initialValue: true, + }) + if (p.isCancel(confirmed) || !confirmed) return [] + + const destRoot = resolve(cwd, '.claude', 'skills') + mkdirSync(destRoot, { recursive: true }) + + const copied: string[] = [] + for (const name of available) { + const src = join(bundledRoot, name) + const dest = join(destRoot, name) + try { + cpSync(src, dest, { recursive: true, force: true }) + copied.push(name) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.warn(`Failed to install skill ${name}: ${message}`) + } + } + + if (copied.length > 0) { + p.log.success( + `Installed ${copied.length} skill(s) into ./.claude/skills/: ${copied.join(', ')}`, + ) + } + + return copied +} + +/** + * Locate the `skills/` directory bundled with this CLI. `tsup` copies the + * monorepo's top-level `skills/` into `dist/skills/`, so the build sits + * alongside the compiled binary regardless of where pnpm/npm installs it. + * + * Walks up from the current file looking for a sibling `skills` dir so + * both the library entry (`dist/index.js`) and the CLI entry + * (`dist/bin/stash.js`) can find it. + */ +function resolveBundledSkillsRoot(): string | undefined { + const here = currentDir() + const candidates = [ + join(here, 'skills'), + join(here, '..', 'skills'), + join(here, '..', '..', 'skills'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return resolve(candidate) + } + return undefined +} + +function currentDir(): string { + if (typeof import.meta?.url === 'string' && import.meta.url) { + return dirname(fileURLToPath(import.meta.url)) + } + return __dirname +} diff --git a/packages/cli/src/commands/wizard/lib/post-agent.ts b/packages/cli/src/commands/wizard/lib/post-agent.ts index efb63752..2c325c0d 100644 --- a/packages/cli/src/commands/wizard/lib/post-agent.ts +++ b/packages/cli/src/commands/wizard/lib/post-agent.ts @@ -9,6 +9,7 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' +import { rewriteEncryptedAlterColumns } from '../../db/rewrite-migrations.js' import type { GatheredContext } from './gather.js' import type { Integration } from './types.js' @@ -18,6 +19,12 @@ interface PostAgentOptions { gathered: GatheredContext } +/** + * Candidate directories drizzle-kit may write migrations to. We check in + * order and rewrite the first one that exists; `drizzle` is the default. + */ +const DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations'] + /** * Run all post-agent steps: install packages, push config, run migrations. */ @@ -32,12 +39,14 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { cwd, ) - // Step 2: Run npx @cipherstash/cli db setup if needed + // Step 2: Run npx @cipherstash/cli db install if the project doesn't yet + // have a stash.config.ts. `db install` scaffolds the config and installs + // EQL in a single step (CIP-2986). if (!gathered.hasStashConfig) { await runStep( - 'Running npx @cipherstash/cli db setup...', - 'npx @cipherstash/cli db setup complete', - 'npx @cipherstash/cli db setup', + 'Running npx @cipherstash/cli db install...', + 'npx @cipherstash/cli db install complete', + 'npx @cipherstash/cli db install', cwd, ) } @@ -59,6 +68,10 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { cwd, ) + // Rewrite any `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` that + // drizzle-kit just produced — those fail in Postgres. CIP-2991 + CIP-2994. + await rewriteEncryptedMigrations(cwd) + const shouldMigrate = await p.confirm({ message: 'Run the migration now? (npx drizzle-kit migrate)', initialValue: true, @@ -76,7 +89,8 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { if (integration === 'prisma') { const shouldMigrate = await p.confirm({ - message: 'Run Prisma migration now? (npx prisma migrate dev --name add-encryption)', + message: + 'Run Prisma migration now? (npx prisma migrate dev --name add-encryption)', initialValue: true, }) @@ -91,6 +105,33 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { } } +async function rewriteEncryptedMigrations(cwd: string): Promise { + for (const dir of DRIZZLE_OUT_DIRS) { + const abs = resolve(cwd, dir) + if (!existsSync(abs)) continue + + try { + const rewritten = await rewriteEncryptedAlterColumns(abs) + if (rewritten.length > 0) { + p.log.info( + `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, + ) + for (const file of rewritten) p.log.step(` - ${file}`) + p.log.warn( + 'If any of these tables already have rows, backfill the new column via @cipherstash/stack before running the migration in production. See the comments in the rewritten SQL.', + ) + } + // Only rewrite the first dir that matches — running again on a + // different candidate would double-transform already-rewritten SQL. + return + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`) + return + } + } +} + async function runStep( startMsg: string, doneMsg: string, diff --git a/packages/cli/src/commands/wizard/lib/prerequisites.ts b/packages/cli/src/commands/wizard/lib/prerequisites.ts index 35156087..1f15e384 100644 --- a/packages/cli/src/commands/wizard/lib/prerequisites.ts +++ b/packages/cli/src/commands/wizard/lib/prerequisites.ts @@ -26,7 +26,7 @@ export function checkPrerequisites(cwd: string): PrerequisiteResult { // Check stash.config.ts if (!findStashConfig(cwd)) { missing.push( - 'No stash.config.ts found. Run: npx @cipherstash/cli db setup', + 'No stash.config.ts found. Run: npx @cipherstash/cli db install', ) } diff --git a/packages/cli/src/commands/wizard/lib/wire-call-sites.ts b/packages/cli/src/commands/wizard/lib/wire-call-sites.ts new file mode 100644 index 00000000..e791d6da --- /dev/null +++ b/packages/cli/src/commands/wizard/lib/wire-call-sites.ts @@ -0,0 +1,175 @@ +import { readFile } from 'node:fs/promises' +import { glob } from 'node:fs/promises' +import { relative } from 'node:path' +import type { Integration } from './types.js' + +/** + * After the agent converts a user's Drizzle/Supabase schemas to encrypted + * columns, their existing server-action / page / API-route code still + * treats those fields as plain text or numbers. We can't safely rewrite + * those call sites automatically (risk is too high and agents can reason + * about the domain better than a regex), but we can point the user at every + * place that needs attention — that's the goal of this report. CIP-2995. + * + * This module is intentionally **report-only**: we read files, print a + * summary, and write the same summary into the wizard log. No mutations. + */ + +export interface CallSiteMatch { + file: string + line: number + snippet: string + kind: 'insert' | 'update' | 'select' +} + +/** + * Scan the project for places that insert/update/select rows on one of the + * tables the user encrypted, so we can tell them to wrap those calls with + * `encryptModel` / `decryptModel`. + * + * The patterns are conservative on purpose — false positives clutter the + * report, so we only match the common idioms. Users with custom query + * builders will see a smaller report and get pointed at the docs. + */ +export async function scanCallSites( + cwd: string, + tables: readonly string[], + integration: Integration, +): Promise { + if (tables.length === 0) return [] + + const files: string[] = [] + const patterns = [ + 'src/app/**/*.ts', + 'src/app/**/*.tsx', + 'src/lib/**/*.ts', + 'src/lib/**/*.tsx', + 'app/**/*.ts', + 'app/**/*.tsx', + 'lib/**/*.ts', + 'lib/**/*.tsx', + ] + for (const pattern of patterns) { + for await (const match of glob(pattern, { cwd })) { + files.push(match) + } + } + + const results: CallSiteMatch[] = [] + + for (const relPath of files) { + const absPath = `${cwd.replace(/\/$/, '')}/${relPath}` + let text: string + try { + text = await readFile(absPath, 'utf-8') + } catch { + continue + } + + const matches = findMatches(text, tables, integration) + for (const m of matches) { + results.push({ ...m, file: relative(cwd, absPath) }) + } + } + + return results +} + +function findMatches( + source: string, + tables: readonly string[], + integration: Integration, +): Array> { + const lines = source.split('\n') + const out: Array> = [] + + const tablesPattern = tables.map(escapeRegex).join('|') + if (!tablesPattern) return out + + // Drizzle idioms: `db.insert(foo)`, `db.update(foo).set(`, `.from(foo)`. + // Supabase idioms: `.from('foo').insert(`, `.from('foo').update(`, + // `.from('foo').select(`. + const drizzleInsert = new RegExp(`\\.insert\\(\\s*(?:${tablesPattern})\\b`) + const drizzleUpdate = new RegExp(`\\.update\\(\\s*(?:${tablesPattern})\\b`) + const drizzleSelect = new RegExp(`\\.from\\(\\s*(?:${tablesPattern})\\b`) + + const supabaseFromInsert = new RegExp( + `\\.from\\(\\s*['"\`](?:${tablesPattern})['"\`]\\s*\\)[\\s\\S]{0,80}?\\.insert\\b`, + ) + const supabaseFromUpdate = new RegExp( + `\\.from\\(\\s*['"\`](?:${tablesPattern})['"\`]\\s*\\)[\\s\\S]{0,80}?\\.update\\b`, + ) + const supabaseFromSelect = new RegExp( + `\\.from\\(\\s*['"\`](?:${tablesPattern})['"\`]\\s*\\)[\\s\\S]{0,80}?\\.select\\b`, + ) + + lines.forEach((line, i) => { + if (integration === 'drizzle') { + if (drizzleInsert.test(line)) + out.push({ kind: 'insert', line: i + 1, snippet: line.trim() }) + if (drizzleUpdate.test(line)) + out.push({ kind: 'update', line: i + 1, snippet: line.trim() }) + if (drizzleSelect.test(line)) + out.push({ kind: 'select', line: i + 1, snippet: line.trim() }) + } else if (integration === 'supabase') { + if (supabaseFromInsert.test(line)) + out.push({ kind: 'insert', line: i + 1, snippet: line.trim() }) + if (supabaseFromUpdate.test(line)) + out.push({ kind: 'update', line: i + 1, snippet: line.trim() }) + if (supabaseFromSelect.test(line)) + out.push({ kind: 'select', line: i + 1, snippet: line.trim() }) + } + }) + + return out +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Render the scan result as a multi-line markdown section, ready for both + * terminal output and persistence into the wizard log. + */ +export function renderCallSiteReport( + matches: readonly CallSiteMatch[], +): string { + if (matches.length === 0) { + return 'No encrypted-table call sites found in src/app, src/lib, app/, or lib/.' + } + + const byFile = new Map() + for (const m of matches) { + const list = byFile.get(m.file) ?? [] + list.push(m) + byFile.set(m.file, list) + } + + const lines: string[] = [] + lines.push( + `Found ${matches.length} call site(s) that may need encryptModel/decryptModel wiring:`, + ) + lines.push('') + for (const [file, fileMatches] of byFile) { + lines.push(`- \`${file}\``) + for (const m of fileMatches) { + lines.push(` - line ${m.line} (${m.kind}): \`${m.snippet}\``) + } + } + lines.push('') + lines.push('Recommended pattern:') + lines.push('```ts') + lines.push('// Writes: encrypt before hitting the DB.') + lines.push( + 'const encrypted = (await encryptionClient.encryptModel(plain, table).run()).data', + ) + lines.push('') + lines.push('// Reads: decrypt after the DB returns.') + lines.push( + 'const plain = (await encryptionClient.decryptModel(row).run()).data', + ) + lines.push('```') + + return lines.join('\n') +} diff --git a/packages/cli/src/commands/wizard/run.ts b/packages/cli/src/commands/wizard/run.ts index 29650fa3..a29ba08a 100644 --- a/packages/cli/src/commands/wizard/run.ts +++ b/packages/cli/src/commands/wizard/run.ts @@ -1,5 +1,9 @@ import * as p from '@clack/prompts' +import { fetchIntegrationPrompt } from './agent/fetch-prompt.js' +import { initializeAgent } from './agent/interface.js' +import { checkReadiness } from './health-checks/index.js' import { + shutdownAnalytics, trackAgentStarted, trackFrameworkDetected, trackFrameworkSelected, @@ -8,21 +12,20 @@ import { trackWizardCompleted, trackWizardError, trackWizardStarted, - shutdownAnalytics, } from './lib/analytics.js' +import { WizardChangelog } from './lib/changelog.js' import { INTEGRATIONS } from './lib/constants.js' import { detectIntegration, detectPackageManager, detectTypeScript, } from './lib/detect.js' -import { checkPrerequisites } from './lib/prerequisites.js' import { gatherContext } from './lib/gather.js' -import type { Integration, WizardSession } from './lib/types.js' -import { checkReadiness } from './health-checks/index.js' -import { fetchIntegrationPrompt } from './agent/fetch-prompt.js' -import { initializeAgent } from './agent/interface.js' +import { maybeInstallSkills } from './lib/install-skills.js' import { runPostAgentSteps } from './lib/post-agent.js' +import { checkPrerequisites } from './lib/prerequisites.js' +import type { Integration, WizardSession } from './lib/types.js' +import { renderCallSiteReport, scanCallSites } from './lib/wire-call-sites.js' interface RunOptions { cwd: string @@ -34,6 +37,11 @@ export async function run(options: RunOptions) { p.intro('CipherStash Wizard') const startTime = Date.now() + const changelog = new WizardChangelog(options.cwd) + changelog.phase( + 'Session start', + `cwd: \`${options.cwd}\`\ncli version: \`${options.cliVersion}\``, + ) // Phase 1: Prerequisites const prereqs = checkPrerequisites(options.cwd) @@ -102,7 +110,14 @@ export async function run(options: RunOptions) { selectedIntegration = await selectIntegration() } - trackFrameworkSelected(selectedIntegration, selectedIntegration === detectedIntegration) + trackFrameworkSelected( + selectedIntegration, + selectedIntegration === detectedIntegration, + ) + changelog.phase( + 'Integration selected', + `\`${selectedIntegration}\` (detected: ${detectedIntegration ?? 'none'})`, + ) // Phase 5: Gather context — DB introspection, column selection, schema files // All done via CLI prompts BEFORE the agent starts. No AI tokens spent on discovery. @@ -112,6 +127,14 @@ export async function run(options: RunOptions) { packageManager, ) + const encryptedTables = Array.from( + new Set(gathered.selectedColumns.map((c) => c.tableName)), + ) + changelog.phase( + 'Columns selected', + `${gathered.selectedColumns.length} column(s) across ${encryptedTables.length} table(s): ${encryptedTables.map((t) => `\`${t}\``).join(', ')}`, + ) + // Phase 6: Build session const session: WizardSession = { cwd: options.cwd, @@ -149,25 +172,74 @@ export async function run(options: RunOptions) { const result = await agent.run(fetched.prompt) if (result.success) { + changelog.phase( + 'Agent completed', + 'Encryption client and schema wiring generated successfully.', + ) + // Phase 8: Run deterministic post-agent steps (install, push, migrate) await runPostAgentSteps({ cwd: options.cwd, integration: selectedIntegration, gathered, }) + changelog.phase( + 'Post-agent steps complete', + 'Package install, `db install`, `db push`, and migrations finished.', + ) + + // Phase 9: Report call sites that still need encryptModel/decryptModel + // wiring. Report-only — we don't mutate these files (CIP-2995). + try { + const matches = await scanCallSites( + options.cwd, + encryptedTables, + selectedIntegration, + ) + const report = renderCallSiteReport(matches) + p.note(report, 'Server action & page call sites') + changelog.phase('Call-site scan', report) + } catch (err) { + p.log.warn( + `Could not scan for call sites: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + // Phase 10: Offer to install Claude skills into .claude/skills (CIP-2992). + const installedSkills = await maybeInstallSkills( + options.cwd, + selectedIntegration, + ) + if (installedSkills.length > 0) { + changelog.action( + `Installed ${installedSkills.length} Claude skill(s).`, + installedSkills.map((name) => `.claude/skills/${name}`), + ) + } trackWizardCompleted(selectedIntegration, Date.now() - startTime) - p.outro('Encryption is set up! Your data is now protected by CipherStash.') + const logPath = await changelog.flush() + if (logPath) { + p.log.info(`Wizard log written to ${logPath}`) + } + p.outro( + 'Encryption is set up! Your data is now protected by CipherStash.', + ) } else { trackWizardError(result.error ?? 'unknown', selectedIntegration) + changelog.note(`Agent failed: ${result.error ?? 'unknown error'}`) + await changelog.flush() p.log.error(result.error ?? 'Agent failed without a specific error.') p.outro('Wizard could not complete. See above for details.') await shutdownAnalytics() process.exit(1) } } catch (error) { - const message = error instanceof Error ? error.message : 'Agent execution failed.' + const message = + error instanceof Error ? error.message : 'Agent execution failed.' trackWizardError(message, selectedIntegration) + changelog.note(`Wizard threw: ${message}`) + await changelog.flush() p.log.error(message) await shutdownAnalytics() process.exit(1) diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 885313c4..68b46f98 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' -import { resolve, dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import pg from 'pg' const EQL_INSTALL_URL = @@ -47,6 +47,14 @@ function bundledSqlPath(filename: string): string { export interface PermissionCheckResult { ok: boolean missing: string[] + /** + * Whether the connected role is a Postgres superuser. Managed Postgres + * providers (Supabase, Neon, RDS, etc.) do not grant superuser, which means + * `CREATE OPERATOR FAMILY` / `CREATE OPERATOR CLASS` in the EQL install + * script will fail. Callers use this to auto-fall back to the + * no-operator-family install variant (OPE index only) instead of aborting. + */ + isSuperuser: boolean } export class EQLInstaller { @@ -86,7 +94,7 @@ export class EQLInstaller { const isSuperuser = role?.rolsuper === true if (isSuperuser) { - return { ok: true, missing: [] } + return { ok: true, missing: [], isSuperuser: true } } // Not a superuser — check individual permissions @@ -125,7 +133,7 @@ export class EQLInstaller { } } - return { ok: missing.length === 0, missing } + return { ok: missing.length === 0, missing, isSuperuser: false } } catch (error) { const detail = error instanceof Error ? error.message : String(error) throw new Error(`Failed to connect to database: ${detail}`, { @@ -385,11 +393,27 @@ export function loadBundledEqlSql( /** * Download the latest EQL install SQL from GitHub. Used by the Drizzle migration path * when `--latest` is passed. + * + * Supabase uses the same GitHub asset as the no-operator-family variant — + * treating either flag as "no operator families" keeps the intent explicit + * even though the underlying URL is the same. */ export async function downloadEqlSql( - excludeOperatorFamily = false, + options: + | { excludeOperatorFamily?: boolean; supabase?: boolean } + | boolean = false, ): Promise { - const url = excludeOperatorFamily + const normalized = + typeof options === 'boolean' + ? { excludeOperatorFamily: options, supabase: false } + : { + excludeOperatorFamily: options.excludeOperatorFamily ?? false, + supabase: options.supabase ?? false, + } + + const useNoOperatorFamilyUrl = + normalized.excludeOperatorFamily || normalized.supabase + const url = useNoOperatorFamilyUrl ? EQL_INSTALL_NO_OPERATOR_FAMILY_URL : EQL_INSTALL_URL diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e4b46883..856df3f7 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -21,6 +21,9 @@ export default defineConfig([ onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package cpSync('src/sql', 'dist/sql', { recursive: true }) + // Copy Claude skills into dist so the wizard can optionally install them + // into the user's project (CIP-2992). Source lives at the monorepo root. + cpSync('../../skills', 'dist/skills', { recursive: true }) }, }, { diff --git a/packages/stack/src/drizzle/index.ts b/packages/stack/src/drizzle/index.ts index 02d3adee..bc4e1eda 100644 --- a/packages/stack/src/drizzle/index.ts +++ b/packages/stack/src/drizzle/index.ts @@ -3,6 +3,14 @@ import { customType } from 'drizzle-orm/pg-core' export type { CastAs, MatchIndexOpts, TokenFilter } +// The encrypted column type is created by the EQL install script in the +// `public` schema (see packages/cli/src/installer/index.ts). Emitting the +// fully-qualified, quoted identifier here means drizzle-kit writes +// `"public"."eql_v2_encrypted"` into generated migrations instead of +// `"undefined"."eql_v2_encrypted"`, which was the symptom that drizzle-kit +// couldn't resolve against the database. +const EQL_ENCRYPTED_DATA_TYPE = '"public"."eql_v2_encrypted"' + /** * Configuration for encrypted column indexes and data types */ @@ -93,7 +101,7 @@ export const encryptedType = ( // Create the Drizzle custom type const customColumnType = customType<{ data: TData; driverData: string }>({ dataType() { - return 'eql_v2_encrypted' + return EQL_ENCRYPTED_DATA_TYPE }, toDriver(value: TData): string { const jsonStr = JSON.stringify(value) @@ -165,14 +173,20 @@ export function getEncryptedColumnConfig( // biome-ignore lint/suspicious/noExplicitAny: Drizzle column types don't expose all properties const columnAny = column as any - // Check if it's an encrypted column by checking sqlName or dataType - // After pgTable processes it, sqlName will be 'eql_v2_encrypted' + // Check if it's an encrypted column by checking sqlName or dataType. + // We accept both the fully-qualified `"public"."eql_v2_encrypted"` form + // that `encryptedType` now emits and the bare `eql_v2_encrypted` form + // that earlier versions produced, for back-compat with tables built + // against older releases. + const isEncryptedTypeString = (value: unknown): boolean => + value === EQL_ENCRYPTED_DATA_TYPE || value === 'eql_v2_encrypted' + const isEncrypted = - columnAny.sqlName === 'eql_v2_encrypted' || - columnAny.dataType === 'eql_v2_encrypted' || + isEncryptedTypeString(columnAny.sqlName) || + isEncryptedTypeString(columnAny.dataType) || (columnAny.dataType && typeof columnAny.dataType === 'function' && - columnAny.dataType() === 'eql_v2_encrypted') + isEncryptedTypeString(columnAny.dataType())) if (isEncrypted) { // Try to get config from property (if still there)