From 5d3eb13ba55148455a858c25de8018770bad31c7 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 29 Apr 2026 18:23:43 -0600 Subject: [PATCH] feat: split the wizard into a seperate package --- .changeset/init-friction-cuts.md | 12 + .changeset/wizard-extracted-package.md | 9 + .changeset/wizard-initial-release.md | 16 ++ packages/cli/README.md | 45 ++-- packages/cli/package.json | 3 +- packages/cli/src/bin/stash.ts | 12 - .../cli/src/commands/db/client-scaffold.ts | 52 ++++ packages/cli/src/commands/db/install.ts | 10 +- packages/cli/src/commands/init/index.ts | 2 - .../cli/src/commands/init/providers/base.ts | 22 +- .../src/commands/init/providers/drizzle.ts | 8 +- .../src/commands/init/providers/supabase.ts | 12 +- .../src/commands/init/steps/authenticate.ts | 29 +-- .../src/commands/init/steps/build-schema.ts | 54 +++-- .../src/commands/init/steps/install-forge.ts | 133 +++++------ .../commands/init/steps/select-connection.ts | 23 -- packages/cli/src/commands/init/types.ts | 21 -- packages/cli/src/commands/init/utils.ts | 22 ++ packages/cli/src/commands/schema/build.ts | 41 ---- packages/cli/tsup.config.ts | 3 - packages/wizard/README.md | 45 ++++ packages/wizard/package.json | 49 ++++ .../src}/__tests__/agent-sdk.test.ts | 0 .../src}/__tests__/commandments.test.ts | 0 .../src}/__tests__/detect.test.ts | 0 .../src}/__tests__/format.test.ts | 0 .../src}/__tests__/gateway-messages.test.ts | 0 .../src}/__tests__/health-checks.test.ts | 0 .../src}/__tests__/hooks.test.ts | 0 .../src}/__tests__/interface.test.ts | 0 .../src}/__tests__/wizard-tools.test.ts | 0 .../src}/agent/commandments.ts | 0 .../wizard => wizard/src}/agent/errors.ts | 0 .../src}/agent/fetch-prompt.ts | 0 .../wizard => wizard/src}/agent/hooks.ts | 0 .../wizard => wizard/src}/agent/interface.ts | 0 packages/wizard/src/bin/wizard.ts | 77 ++++++ .../src}/health-checks/index.ts | 0 .../wizard => wizard/src}/lib/analytics.ts | 0 .../wizard => wizard/src}/lib/changelog.ts | 0 .../wizard => wizard/src}/lib/constants.ts | 0 .../wizard => wizard/src}/lib/detect.ts | 0 .../wizard => wizard/src}/lib/format.ts | 0 .../wizard => wizard/src}/lib/gather.ts | 0 .../src}/lib/install-skills.ts | 0 .../wizard => wizard/src}/lib/post-agent.ts | 2 +- .../src}/lib/prerequisites.ts | 0 packages/wizard/src/lib/rewrite-migrations.ts | 91 +++++++ .../wizard => wizard/src}/lib/types.ts | 0 .../src}/lib/wire-call-sites.ts | 0 .../src/commands/wizard => wizard/src}/run.ts | 0 .../src}/tools/wizard-tools.ts | 0 packages/wizard/tsconfig.json | 23 ++ packages/wizard/tsup.config.ts | 28 +++ pnpm-lock.yaml | 46 +++- skills/stash-cli/SKILL.md | 223 ++++++++++-------- 56 files changed, 728 insertions(+), 385 deletions(-) create mode 100644 .changeset/init-friction-cuts.md create mode 100644 .changeset/wizard-extracted-package.md create mode 100644 .changeset/wizard-initial-release.md create mode 100644 packages/cli/src/commands/db/client-scaffold.ts delete mode 100644 packages/cli/src/commands/init/steps/select-connection.ts create mode 100644 packages/wizard/README.md create mode 100644 packages/wizard/package.json rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/agent-sdk.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/commandments.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/detect.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/format.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/gateway-messages.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/health-checks.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/hooks.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/interface.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/__tests__/wizard-tools.test.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/agent/commandments.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/agent/errors.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/agent/fetch-prompt.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/agent/hooks.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/agent/interface.ts (100%) create mode 100644 packages/wizard/src/bin/wizard.ts rename packages/{cli/src/commands/wizard => wizard/src}/health-checks/index.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/analytics.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/changelog.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/constants.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/detect.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/format.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/gather.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/install-skills.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/post-agent.ts (98%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/prerequisites.ts (100%) create mode 100644 packages/wizard/src/lib/rewrite-migrations.ts rename packages/{cli/src/commands/wizard => wizard/src}/lib/types.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/lib/wire-call-sites.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/run.ts (100%) rename packages/{cli/src/commands/wizard => wizard/src}/tools/wizard-tools.ts (100%) create mode 100644 packages/wizard/tsconfig.json create mode 100644 packages/wizard/tsup.config.ts diff --git a/.changeset/init-friction-cuts.md b/.changeset/init-friction-cuts.md new file mode 100644 index 00000000..57df3d4b --- /dev/null +++ b/.changeset/init-friction-cuts.md @@ -0,0 +1,12 @@ +--- +'@cipherstash/cli': minor +--- + +Reduce friction in `stash init`. + +- **No more "How will you connect to your database?" prompt.** Init now auto-detects Drizzle (from `drizzle.config.*` or `drizzle-orm`/`drizzle-kit` in `package.json`) and Supabase (from the host in `DATABASE_URL`), and silently picks the matching encryption client template. Falls back to a generic Postgres template otherwise. +- **No more "Where should we create your encryption client?" prompt.** Init writes to `./src/encryption/index.ts` by default. The "file already exists, what would you like to do?" prompt still appears so existing client files aren't silently overwritten. +- **Single combined dependency-install prompt.** Previously init asked twice (once for `@cipherstash/stack`, once for `@cipherstash/cli`). It now asks once, listing both, and runs the installs in sequence. When both packages are already in `node_modules`, no prompt appears at all. +- **Already-authenticated users skip the "Continue with workspace X?" prompt.** Init logs `Using workspace X` and proceeds. Run `stash auth login` directly to switch workspaces. + +`stash db install` now also calls into the same encryption-client scaffolder as a safety net — users who run `db install` without `init` first still get a working client file generated at the path their `stash.config.ts` points to. diff --git a/.changeset/wizard-extracted-package.md b/.changeset/wizard-extracted-package.md new file mode 100644 index 00000000..abb82e10 --- /dev/null +++ b/.changeset/wizard-extracted-package.md @@ -0,0 +1,9 @@ +--- +'@cipherstash/cli': minor +--- + +**Breaking:** the `stash wizard` command has been removed. The AI-guided encryption setup is now its own package — run it via `npx @cipherstash/wizard` (or `pnpm dlx`, `bunx`, `yarn dlx`). + +The wizard was pulling `@anthropic-ai/claude-agent-sdk` (47MB unpacked) into every `npx @cipherstash/cli` invocation, even for fast commands like `init`, `auth`, and `db install`. Splitting it out keeps cli's dependency tree small and lets each package manager handle the wizard's install natively — no more shelling out to `npm` from inside the cli, no Yarn PnP / Bun-only failure modes. + +The next-steps output from `init` and `db install` still recommends `npx @cipherstash/wizard` as the automated path. The `schema build` command no longer offers a wizard/builder selection prompt — it goes straight to the schema builder. diff --git a/.changeset/wizard-initial-release.md b/.changeset/wizard-initial-release.md new file mode 100644 index 00000000..c128d098 --- /dev/null +++ b/.changeset/wizard-initial-release.md @@ -0,0 +1,16 @@ +--- +'@cipherstash/wizard': minor +--- + +Initial release of `@cipherstash/wizard` — AI-powered encryption setup for CipherStash, extracted from `@cipherstash/cli`. + +Run it once per project, after `stash init`: + +```bash +npx @cipherstash/wizard +pnpm dlx @cipherstash/wizard +yarn dlx @cipherstash/wizard +bunx @cipherstash/wizard +``` + +The wizard reads your codebase, asks which columns to encrypt, hands a surgical prompt to the Claude Agent SDK against the CipherStash-hosted LLM gateway, and runs deterministic post-agent steps (package install, `db install`, `db push`, framework migrations). Same behavior as the previous `stash wizard` command — just shipped as its own package so it doesn't bloat the cli's dependency tree. diff --git a/packages/cli/README.md b/packages/cli/README.md index e4eb8648..76259f72 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@cipherstash/cli.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/cli) [![License: MIT](https://img.shields.io/npm/l/@cipherstash/cli.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) -The single CLI for CipherStash. It handles authentication, project initialization, AI-guided encryption setup, EQL database lifecycle (install, upgrade, validate, push, migrate), schema building, and encrypted secrets management. Install it as a devDependency alongside the runtime SDK `@cipherstash/stack`. +The single CLI for CipherStash. It handles authentication, project initialization, EQL database lifecycle (install, upgrade, validate, push, migrate), schema building, and encrypted secrets management. Install it as a devDependency alongside the runtime SDK `@cipherstash/stack`. --- @@ -14,7 +14,6 @@ npm install -D @cipherstash/cli npx @cipherstash/cli auth login # authenticate with CipherStash 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 ``` What each step does: @@ -22,21 +21,23 @@ 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 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. + +After `db install`, declare which columns to encrypt — either run [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard) to do it automatically, or edit your encryption client file (default `./src/encryption/index.ts`) by hand. --- ## Recommended flow ``` -npx @cipherstash/cli init - └── 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 auth login + └── npx @cipherstash/cli init + └── npx @cipherstash/cli db install + └── npx @cipherstash/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 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. +`@cipherstash/cli` covers authentication, initialization, EQL install/upgrade/validate/push/migrate, and schema introspection. The wizard ([`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard)) is a separate package that calls back into these cli commands after its AI agent finishes editing your schema files. --- @@ -79,7 +80,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 install`, 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 edit your encryption client file directly. --- @@ -91,25 +92,7 @@ Authenticate with CipherStash using a browser-based device code flow. npx @cipherstash/cli auth login ``` -Saves the token to `~/.cipherstash/auth.json`. The wizard checks for this file as a prerequisite before running. - ---- - -### `npx @cipherstash/cli wizard` - -AI-powered encryption setup. The wizard reads your codebase, detects your framework, introspects your database schema, and edits your existing schema files to add encrypted column definitions. - -```bash -npx @cipherstash/cli wizard -``` - -Prerequisites: -- Authenticated (`npx @cipherstash/cli auth login` completed). -- `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. - -The wizard uses the CipherStash-hosted LLM gateway. No Anthropic API key is required. +Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running. --- @@ -286,7 +269,7 @@ Build an encryption client file from your database schema using DB introspection npx @cipherstash/cli schema build [--supabase] ``` -The first prompt offers `npx @cipherstash/cli wizard` as the recommended path. If you choose the manual builder, the command connects to your database, lets you select tables and columns to encrypt, asks about searchable indexes, and generates a typed encryption client file. +Connects to your database, lets you select tables and columns to encrypt, asks about searchable indexes, and generates a typed encryption client file. Reads `databaseUrl` from `stash.config.ts`. @@ -422,7 +405,7 @@ const sql = await downloadEqlSql(true) // no operator family variant ## Relationship to `@cipherstash/stack` -`@cipherstash/stack` is the runtime SDK. It stays lean with no heavy dependencies like `pg` and ships in your production bundle. `@cipherstash/cli` is a devDependency: it handles database tooling, AI-guided setup, and schema lifecycle at development time. Think of it like Drizzle Kit — a companion tool that prepares the database while the runtime SDK handles queries. +`@cipherstash/stack` is the runtime SDK. It stays lean with no heavy dependencies like `pg` and ships in your production bundle. `@cipherstash/cli` is a devDependency: it handles database tooling and schema lifecycle at development time. Think of it like Drizzle Kit — a companion tool that prepares the database while the runtime SDK handles queries. --- diff --git a/packages/cli/package.json b/packages/cli/package.json index b9f4e0a1..00a2a542 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@cipherstash/cli", "version": "0.8.0", - "description": "CipherStash CLI — the one stash command for auth, init, encryption schema, database setup, secrets, and the AI wizard.", + "description": "CipherStash CLI — the one stash command for auth, init, encryption schema, database setup, and secrets.", "license": "MIT", "author": "CipherStash ", "files": [ @@ -40,7 +40,6 @@ "lint": "biome check ." }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.87", "@cipherstash/auth": "catalog:repo", "@clack/prompts": "0.10.1", "dotenv": "16.4.7", diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index e16bd8e3..fa31d2bc 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -61,7 +61,6 @@ Usage: npx @cipherstash/cli [options] Commands: init Initialize CipherStash for your project auth Authenticate with CipherStash - wizard AI-powered encryption setup (reads your codebase) db install Scaffold stash.config.ts (if missing) and install EQL extensions db upgrade Upgrade EQL extensions to the latest version @@ -98,7 +97,6 @@ Examples: npx @cipherstash/cli init npx @cipherstash/cli init --supabase npx @cipherstash/cli auth login - npx @cipherstash/cli wizard npx @cipherstash/cli db install npx @cipherstash/cli db push npx @cipherstash/cli schema build @@ -248,16 +246,6 @@ async function main() { await authCommand(authArgs, flags) break } - case 'wizard': { - // Lazy-load the wizard so the agent SDK is only imported when needed. - const { run } = await import('../commands/wizard/run.js') - await run({ - cwd: process.cwd(), - debug: flags.debug, - cliVersion: pkg.version, - }) - break - } case 'db': await runDbCommand(subcommand, flags, values) break diff --git a/packages/cli/src/commands/db/client-scaffold.ts b/packages/cli/src/commands/db/client-scaffold.ts new file mode 100644 index 00000000..d04e26e2 --- /dev/null +++ b/packages/cli/src/commands/db/client-scaffold.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import * as p from '@clack/prompts' +import type { Integration } from '../init/types.js' +import { generatePlaceholderClient } from '../init/utils.js' +import { detectDrizzle, detectSupabase } from './detect.js' + +/** + * Pick a placeholder template using the same signals `db install` already + * detects. Drizzle wins over Supabase when both look present (a Drizzle-on- + * Supabase project is more naturally scaffolded as Drizzle). + */ +function detectIntegration( + cwd: string, + databaseUrl: string | undefined, +): Integration { + if (detectDrizzle(cwd)) return 'drizzle' + if (detectSupabase(databaseUrl)) return 'supabase' + return 'postgresql' +} + +/** + * Scaffold an encryption client file at `clientPath` if one doesn't exist. + * No-op when the file is already present. Silent — never prompts. + * + * `init`'s `buildSchemaStep` is the primary path that creates this file + * (and handles the "file already exists" case interactively). This function + * exists as a safety net for users who run `db install` directly without + * `init` first — they still get a working client file rather than failing + * later when the config tries to load a non-existent path. + */ +export function ensureEncryptionClient( + clientPath: string, + cwd: string = process.cwd(), + databaseUrl: string | undefined = process.env.DATABASE_URL, +): void { + const resolved = resolve(cwd, clientPath) + if (existsSync(resolved)) return + + const integration = detectIntegration(cwd, databaseUrl) + const contents = generatePlaceholderClient(integration) + + const dir = dirname(resolved) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(resolved, contents, 'utf-8') + + p.log.success( + `Scaffolded encryption client at ${clientPath} (${integration} template)`, + ) +} diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index be0077e2..02d46e2c 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -9,6 +9,7 @@ import { loadBundledEqlSql, } from '@/installer/index.js' import * as p from '@clack/prompts' +import { ensureEncryptionClient } from './client-scaffold.js' import { ensureStashConfig } from './config-scaffold.js' import { type SupabaseProjectInfo, @@ -86,6 +87,11 @@ export async function installCommand(options: InstallOptions) { const config = await loadStashConfig() s.stop('Configuration loaded.') + // Safety net: if the user ran `db install` without first running `init`, + // scaffold the encryption client file so config.client points somewhere + // real. No-op when the file already exists. + ensureEncryptionClient(config.client, process.cwd(), config.databaseUrl) + // Auto-detect provider hints when the user didn't explicitly pass flags. // CIP-2985. const resolved = resolveProviderOptions(options, config.databaseUrl) @@ -258,8 +264,8 @@ function printNextSteps(): void { [ 'Next steps:', '', - ' 1. Wire up encrypt/decrypt with the wizard:', - ' npx @cipherstash/cli wizard', + ' 1. Wire up encrypt/decrypt with the wizard (AI-guided, automated):', + ' npx @cipherstash/wizard', '', ' 2. Or use the client directly from @cipherstash/stack:', " import { Encryption } from '@cipherstash/stack'", diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 42ffbfc8..4e395c50 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -6,7 +6,6 @@ import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' import { installForgeStep } from './steps/install-forge.js' import { nextStepsStep } from './steps/next-steps.js' -import { selectConnectionStep } from './steps/select-connection.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' @@ -17,7 +16,6 @@ const PROVIDER_MAP: Record InitProvider> = { const STEPS = [ authenticateStep, - selectConnectionStep, buildSchemaStep, installForgeStep, nextStepsStep, diff --git a/packages/cli/src/commands/init/providers/base.ts b/packages/cli/src/commands/init/providers/base.ts index ece73482..ef135a1c 100644 --- a/packages/cli/src/commands/init/providers/base.ts +++ b/packages/cli/src/commands/init/providers/base.ts @@ -4,26 +4,16 @@ export function createBaseProvider(): InitProvider { return { name: 'base', introMessage: 'Setting up CipherStash for your project...', - connectionOptions: [ - { value: 'drizzle', label: 'Drizzle ORM' }, - { value: 'supabase-js', label: 'Supabase JS Client' }, - { value: 'prisma', label: 'Prisma' }, - { value: 'raw-sql', label: 'Raw SQL / pg' }, - ], getNextSteps(state: InitState): string[] { - const steps = ['Set up your database: npx @cipherstash/cli db install'] - const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' - steps.push( - `Customize your schema: npx @cipherstash/cli wizard (AI-guided, automated) — or ${manualEdit}`, - ) - - steps.push('Quickstart: https://cipherstash.com/docs/stack/quickstart') - steps.push('Dashboard: https://dashboard.cipherstash.com/workspaces') - - return steps + return [ + 'Set up your database: npx @cipherstash/cli db install', + `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, + 'Quickstart: https://cipherstash.com/docs/stack/quickstart', + 'Dashboard: https://dashboard.cipherstash.com/workspaces', + ] }, } } diff --git a/packages/cli/src/commands/init/providers/drizzle.ts b/packages/cli/src/commands/init/providers/drizzle.ts index 65fde890..33423a11 100644 --- a/packages/cli/src/commands/init/providers/drizzle.ts +++ b/packages/cli/src/commands/init/providers/drizzle.ts @@ -4,12 +4,6 @@ export function createDrizzleProvider(): InitProvider { return { name: 'drizzle', introMessage: 'Setting up CipherStash for your Drizzle project...', - connectionOptions: [ - { value: 'drizzle', label: 'Drizzle ORM', hint: 'recommended' }, - { value: 'supabase-js', label: 'Supabase JS Client' }, - { value: 'prisma', label: 'Prisma' }, - { value: 'raw-sql', label: 'Raw SQL / pg' }, - ], getNextSteps(state: InitState): string[] { const steps = ['Set up your database: npx @cipherstash/cli db install --drizzle'] @@ -17,7 +11,7 @@ export function createDrizzleProvider(): InitProvider { ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' steps.push( - `Customize your schema: npx @cipherstash/cli wizard (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, ) steps.push( diff --git a/packages/cli/src/commands/init/providers/supabase.ts b/packages/cli/src/commands/init/providers/supabase.ts index f9efc9bd..9f862f48 100644 --- a/packages/cli/src/commands/init/providers/supabase.ts +++ b/packages/cli/src/commands/init/providers/supabase.ts @@ -4,16 +4,6 @@ export function createSupabaseProvider(): InitProvider { return { name: 'supabase', introMessage: 'Setting up CipherStash for your Supabase project...', - connectionOptions: [ - { - value: 'supabase-js', - label: 'Supabase JS Client', - hint: 'recommended', - }, - { value: 'drizzle', label: 'Drizzle ORM' }, - { value: 'prisma', label: 'Prisma' }, - { value: 'raw-sql', label: 'Raw SQL / pg' }, - ], getNextSteps(state: InitState): string[] { const steps = [ 'Install EQL: npx @cipherstash/cli db install --supabase (prompts for migration vs direct)', @@ -24,7 +14,7 @@ export function createSupabaseProvider(): InitProvider { ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' steps.push( - `Customize your schema: npx @cipherstash/cli wizard (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, ) steps.push( diff --git a/packages/cli/src/commands/init/steps/authenticate.ts b/packages/cli/src/commands/init/steps/authenticate.ts index 588998b3..1f9d5fb2 100644 --- a/packages/cli/src/commands/init/steps/authenticate.ts +++ b/packages/cli/src/commands/init/steps/authenticate.ts @@ -31,31 +31,22 @@ async function checkExistingAuth(): Promise { export const authenticateStep: InitStep = { id: 'authenticate', name: 'Authenticate with CipherStash', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, provider: InitProvider): Promise { const existing = await checkExistingAuth() + // Already authenticated — silently proceed. Users who want to switch + // workspaces can run `stash auth login` directly. Asking on every + // `init` is friction for the common "re-running init in the same repo" + // flow. if (existing) { - const continueExisting = await p.confirm({ - message: `You're logged in to workspace ${existing.workspace} (${existing.regionLabel}). Continue with this workspace?`, - initialValue: true, - }) - - if (p.isCancel(continueExisting)) { - p.cancel('Cancelled.') - process.exit(0) - } - - if (continueExisting) { - p.log.success(`Using workspace ${existing.workspace}`) - return { ...state, authenticated: true } - } - - // User wants a different workspace — fall through to login - p.log.info('Logging in with a different workspace...') + p.log.success( + `Using workspace ${existing.workspace} (${existing.regionLabel})`, + ) + return { ...state, authenticated: true } } const region = await selectRegion() - await login(region, _provider.name) + await login(region, provider.name) await bindDevice() return { ...state, authenticated: true } }, diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index c2a97797..0b966dcd 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -1,34 +1,45 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError, toIntegration } from '../types.js' +import { detectDrizzle, detectSupabase } from '../../db/detect.js' +import type { + Integration, + InitProvider, + InitState, + InitStep, +} from '../types.js' +import { CancelledError } from '../types.js' import { generatePlaceholderClient } from '../utils.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' +/** + * Pick the placeholder template by reading the same signals `db install` + * uses — Drizzle config / dependency for `drizzle`, Supabase host in + * `DATABASE_URL` for `supabase`, otherwise raw Postgres. Silent: never + * prompts the user. + */ +function detectIntegration( + cwd: string, + databaseUrl: string | undefined, +): Integration { + if (detectDrizzle(cwd)) return 'drizzle' + if (detectSupabase(databaseUrl)) return 'supabase' + return 'postgresql' +} + export const buildSchemaStep: InitStep = { id: 'build-schema', name: 'Generate encryption client', async run(state: InitState, _provider: InitProvider): Promise { - if (!state.connectionMethod) { - p.log.warn('Skipping schema generation (no connection method selected)') - return { ...state, schemaGenerated: false } - } - - const integration = toIntegration(state.connectionMethod) - - const clientFilePath = await p.text({ - message: 'Where should we create your encryption client?', - placeholder: DEFAULT_CLIENT_PATH, - defaultValue: DEFAULT_CLIENT_PATH, - }) - - if (p.isCancel(clientFilePath)) throw new CancelledError() - - const resolvedPath = resolve(process.cwd(), clientFilePath) + const cwd = process.cwd() + const integration = detectIntegration(cwd, process.env.DATABASE_URL) + const clientFilePath = DEFAULT_CLIENT_PATH + const resolvedPath = resolve(cwd, clientFilePath) - // If the file already exists, ask what to do + // Existing-file branch is the only place we still prompt — silently + // overwriting someone's encryption client is bad. Everywhere else we + // pick sensible defaults and move on. if (existsSync(resolvedPath)) { const action = await p.select({ message: `${clientFilePath} already exists. What would you like to do?`, @@ -52,14 +63,15 @@ export const buildSchemaStep: InitStep = { const fileContents = generatePlaceholderClient(integration) - // Write the file const dir = dirname(resolvedPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } writeFileSync(resolvedPath, fileContents, 'utf-8') - p.log.success(`Encryption client written to ${clientFilePath}`) + p.log.success( + `Encryption client written to ${clientFilePath} (${integration} template)`, + ) return { ...state, clientFilePath, schemaGenerated: true } }, diff --git a/packages/cli/src/commands/init/steps/install-forge.ts b/packages/cli/src/commands/init/steps/install-forge.ts index e1f3c442..4d9febe1 100644 --- a/packages/cli/src/commands/init/steps/install-forge.ts +++ b/packages/cli/src/commands/init/steps/install-forge.ts @@ -3,87 +3,88 @@ import * as p from '@clack/prompts' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' import { + combinedInstallCommands, detectPackageManager, - devInstallCommand, isPackageInstalled, - prodInstallCommand, } from '../utils.js' const STACK_PACKAGE = '@cipherstash/stack' const FORGE_PACKAGE = '@cipherstash/cli' -/** - * Installs a package if not already present. - * Returns true if installed (or already was), false if skipped or failed. - */ -async function installIfNeeded( - packageName: string, - buildCommand: ( - pm: ReturnType, - pkg: string, - ) => string, - depLabel: string, -): Promise { - if (isPackageInstalled(packageName)) { - p.log.success(`${packageName} is already installed.`) - return true - } +export const installForgeStep: InitStep = { + id: 'install-forge', + name: 'Install stack dependencies', + async run(state: InitState, _provider: InitProvider): Promise { + const stackPresent = isPackageInstalled(STACK_PACKAGE) + const forgePresent = isPackageInstalled(FORGE_PACKAGE) - const pm = detectPackageManager() - const cmd = buildCommand(pm, packageName) + // Both already there — silent success, no prompts. + if (stackPresent && forgePresent) { + p.log.success( + `${STACK_PACKAGE} and ${FORGE_PACKAGE} are already installed.`, + ) + return { ...state, stackInstalled: true, forgeInstalled: true } + } - const install = await p.confirm({ - message: `Install ${packageName} as a ${depLabel} dependency? (${cmd})`, - }) + const pm = detectPackageManager() + const prodPackages = stackPresent ? [] : [STACK_PACKAGE] + const devPackages = forgePresent ? [] : [FORGE_PACKAGE] + const commands = combinedInstallCommands(pm, prodPackages, devPackages) - if (p.isCancel(install)) throw new CancelledError() + const missingList = [ + ...prodPackages.map((pkg) => `${pkg} (prod)`), + ...devPackages.map((pkg) => `${pkg} (dev)`), + ].join(', ') - if (!install) { - p.log.info(`Skipping ${packageName} installation.`) - p.note( - `You can install it manually later:\n ${cmd}`, - 'Manual Installation', - ) - return false - } + const install = await p.confirm({ + message: `Install ${missingList}? (${commands.join(' && ')})`, + }) - // 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}`) + if (p.isCancel(install)) throw new CancelledError() - try { - 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) - p.log.error(`${packageName} installation failed`) - p.log.error(message) - p.note(`You can install it manually:\n ${cmd}`, 'Manual Installation') - return false - } -} + if (!install) { + p.log.info('Skipping package installation.') + p.note( + `You can install them manually later:\n ${commands.join('\n ')}`, + 'Manual Installation', + ) + return { + ...state, + stackInstalled: stackPresent, + forgeInstalled: forgePresent, + } + } -export const installForgeStep: InitStep = { - id: 'install-forge', - 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', - ) + // 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 line + // after, letting the package manager own the terminal in between. + let allSucceeded = true + for (const cmd of commands) { + p.log.step(`Running: ${cmd}`) + try { + execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.error(`Install failed: ${cmd}`) + p.log.error(message) + allSucceeded = false + } + } - // Install @cipherstash/cli as a dev dependency - const forgeInstalled = await installIfNeeded( - FORGE_PACKAGE, - devInstallCommand, - 'dev', - ) + if (allSucceeded) { + p.log.success('Stack dependencies installed.') + } else { + p.note( + `You can retry manually:\n ${commands.join('\n ')}`, + 'Manual Installation', + ) + } - return { ...state, forgeInstalled, stackInstalled } + return { + ...state, + stackInstalled: stackPresent || allSucceeded, + forgeInstalled: forgePresent || allSucceeded, + } }, } diff --git a/packages/cli/src/commands/init/steps/select-connection.ts b/packages/cli/src/commands/init/steps/select-connection.ts deleted file mode 100644 index b0ba880c..00000000 --- a/packages/cli/src/commands/init/steps/select-connection.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as p from '@clack/prompts' -import type { - ConnectionMethod, - InitProvider, - InitState, - InitStep, -} from '../types.js' -import { CancelledError } from '../types.js' - -export const selectConnectionStep: InitStep = { - id: 'select-connection', - name: 'Select connection method', - async run(state: InitState, provider: InitProvider): Promise { - const method = await p.select({ - message: 'How will you connect to your database?', - options: provider.connectionOptions, - }) - - if (p.isCancel(method)) throw new CancelledError() - - return { ...state, connectionMethod: method as ConnectionMethod } - }, -} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 0e49af43..eb703285 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -1,5 +1,3 @@ -export type ConnectionMethod = 'drizzle' | 'supabase-js' | 'prisma' | 'raw-sql' - export type Integration = 'drizzle' | 'supabase' | 'postgresql' export type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' @@ -19,7 +17,6 @@ export interface SchemaDef { export interface InitState { authenticated?: boolean - connectionMethod?: ConnectionMethod clientFilePath?: string schemaGenerated?: boolean stackInstalled?: boolean @@ -35,11 +32,6 @@ export interface InitStep { export interface InitProvider { name: string introMessage: string - connectionOptions: Array<{ - value: ConnectionMethod - label: string - hint?: string - }> getNextSteps(state: InitState): string[] } @@ -49,16 +41,3 @@ export class CancelledError extends Error { this.name = 'CancelledError' } } - -/** Maps a connection method to the code generation integration type. */ -export function toIntegration(method: ConnectionMethod): Integration { - switch (method) { - case 'drizzle': - return 'drizzle' - case 'supabase-js': - return 'supabase' - case 'prisma': - case 'raw-sql': - return 'postgresql' - } -} diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 6c7dda3e..8d289bc1 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -91,6 +91,28 @@ export function devInstallCommand( } } +/** + * Build the install command(s) that add multiple dependencies at once. + * npm/pnpm/yarn/bun all accept a space-separated package list, so we + * use the existing `prodInstallCommand` / `devInstallCommand` builders + * with a joined argument. Returns one or two strings depending on + * whether prod and dev lists are both non-empty. + */ +export function combinedInstallCommands( + pm: PackageManager, + prodPackages: string[], + devPackages: string[], +): string[] { + const commands: string[] = [] + if (prodPackages.length > 0) { + commands.push(prodInstallCommand(pm, prodPackages.join(' '))) + } + if (devPackages.length > 0) { + commands.push(devInstallCommand(pm, devPackages.join(' '))) + } + return commands +} + function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } diff --git a/packages/cli/src/commands/schema/build.ts b/packages/cli/src/commands/schema/build.ts index f075fc5a..599984e0 100644 --- a/packages/cli/src/commands/schema/build.ts +++ b/packages/cli/src/commands/schema/build.ts @@ -345,47 +345,6 @@ export async function builderCommand(options: { supabase?: boolean } = {}) { p.intro('CipherStash Schema Builder') - // Offer the AI-powered wizard as the recommended path - const approach = await p.select({ - message: 'How would you like to build your encryption schema?', - options: [ - { - value: 'ai-wizard', - label: 'Use the CipherStash Wizard (Recommended)', - hint: 'AI-powered — reads your codebase and modifies schemas in place', - }, - { - value: 'builder', - label: 'Use the schema builder', - hint: 'generates a standalone encryption client from your database', - }, - ], - }) - - if (p.isCancel(approach)) { - p.cancel('Cancelled.') - return - } - - if (approach === 'ai-wizard') { - p.note( - [ - 'The CipherStash Wizard uses AI to read your existing codebase,', - 'find your schema definitions, and integrate encryption directly.', - '', - 'Run it with:', - '', - ' npx @cipherstash/cli wizard', - '', - 'It works with Drizzle, Supabase, Prisma, and raw SQL projects.', - 'No Anthropic API key needed — it uses your CipherStash account.', - ].join('\n'), - 'CipherStash Wizard', - ) - p.outro('Run `npx @cipherstash/cli wizard` to get started!') - return - } - // Schema builder flow — uses DB introspection to generate a client file const integration: Integration = options.supabase ? 'supabase' : 'postgresql' diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 856df3f7..e4b46883 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -21,9 +21,6 @@ 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/wizard/README.md b/packages/wizard/README.md new file mode 100644 index 00000000..6d9dff8a --- /dev/null +++ b/packages/wizard/README.md @@ -0,0 +1,45 @@ +# @cipherstash/wizard + +AI-powered encryption setup for CipherStash. Reads your codebase, asks which +columns to encrypt, and wires up `@cipherstash/stack` for you. + +## Usage + +Run it via your package manager's runner — the wizard installs nothing +permanently and is intended to be invoked once per project: + +```bash +npx @cipherstash/wizard # npm / Node +pnpm dlx @cipherstash/wizard # pnpm +yarn dlx @cipherstash/wizard # yarn +bunx @cipherstash/wizard # bun +``` + +## Prerequisites + +Before running the wizard, your project should have: + +- `@cipherstash/cli` available (the wizard shells out to `stash db install` / + `db push` after the agent finishes editing) +- A `stash.config.ts` (or the wizard will run `stash db install` to scaffold one) +- A reachable database via `DATABASE_URL` +- An authenticated CipherStash session (`stash auth login`) + +## What it does + +1. Detects your framework (Drizzle, Supabase, Prisma, generic) and TypeScript usage. +2. Runs health checks against the CipherStash gateway and your database. +3. Prompts you to pick the tables and columns to encrypt. +4. Hands a surgical prompt to the Claude Agent SDK, which edits your schema + and call sites to use `@cipherstash/stack`'s encryption APIs. +5. Runs deterministic post-agent steps: package install, `db install`, + `db push`, framework-specific migrations. +6. Reports remaining call sites that need `encryptModel` / `decryptModel` + wiring. + +The agent runs against a CipherStash-hosted LLM gateway — you authenticate +with your CipherStash account, no Anthropic API key required. + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/packages/wizard/package.json b/packages/wizard/package.json new file mode 100644 index 00000000..f04a86cc --- /dev/null +++ b/packages/wizard/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cipherstash/wizard", + "version": "0.0.0", + "description": "AI-powered encryption setup for CipherStash. Reads your codebase, picks columns to encrypt, and wires everything up.", + "license": "MIT", + "author": "CipherStash ", + "files": [ + "dist", + "dist/skills", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "type": "module", + "bin": { + "stash-wizard": "./dist/bin/wizard.js" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "postbuild": "chmod +x ./dist/bin/wizard.js", + "dev": "tsup --watch", + "test": "vitest run", + "lint": "biome check ." + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.87", + "@cipherstash/auth": "catalog:repo", + "@clack/prompts": "0.10.1", + "dotenv": "16.4.7", + "pg": "8.13.1", + "picocolors": "^1.1.1", + "posthog-node": "^5.28.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/pg": "^8.11.11", + "tsup": "catalog:repo", + "tsx": "catalog:repo", + "typescript": "catalog:repo", + "vitest": "catalog:repo" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/cli/src/commands/wizard/__tests__/agent-sdk.test.ts b/packages/wizard/src/__tests__/agent-sdk.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/agent-sdk.test.ts rename to packages/wizard/src/__tests__/agent-sdk.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/commandments.test.ts b/packages/wizard/src/__tests__/commandments.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/commandments.test.ts rename to packages/wizard/src/__tests__/commandments.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/detect.test.ts b/packages/wizard/src/__tests__/detect.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/detect.test.ts rename to packages/wizard/src/__tests__/detect.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/format.test.ts b/packages/wizard/src/__tests__/format.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/format.test.ts rename to packages/wizard/src/__tests__/format.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/gateway-messages.test.ts b/packages/wizard/src/__tests__/gateway-messages.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/gateway-messages.test.ts rename to packages/wizard/src/__tests__/gateway-messages.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/health-checks.test.ts b/packages/wizard/src/__tests__/health-checks.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/health-checks.test.ts rename to packages/wizard/src/__tests__/health-checks.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/hooks.test.ts b/packages/wizard/src/__tests__/hooks.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/hooks.test.ts rename to packages/wizard/src/__tests__/hooks.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/interface.test.ts b/packages/wizard/src/__tests__/interface.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/interface.test.ts rename to packages/wizard/src/__tests__/interface.test.ts diff --git a/packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts b/packages/wizard/src/__tests__/wizard-tools.test.ts similarity index 100% rename from packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts rename to packages/wizard/src/__tests__/wizard-tools.test.ts diff --git a/packages/cli/src/commands/wizard/agent/commandments.ts b/packages/wizard/src/agent/commandments.ts similarity index 100% rename from packages/cli/src/commands/wizard/agent/commandments.ts rename to packages/wizard/src/agent/commandments.ts diff --git a/packages/cli/src/commands/wizard/agent/errors.ts b/packages/wizard/src/agent/errors.ts similarity index 100% rename from packages/cli/src/commands/wizard/agent/errors.ts rename to packages/wizard/src/agent/errors.ts diff --git a/packages/cli/src/commands/wizard/agent/fetch-prompt.ts b/packages/wizard/src/agent/fetch-prompt.ts similarity index 100% rename from packages/cli/src/commands/wizard/agent/fetch-prompt.ts rename to packages/wizard/src/agent/fetch-prompt.ts diff --git a/packages/cli/src/commands/wizard/agent/hooks.ts b/packages/wizard/src/agent/hooks.ts similarity index 100% rename from packages/cli/src/commands/wizard/agent/hooks.ts rename to packages/wizard/src/agent/hooks.ts diff --git a/packages/cli/src/commands/wizard/agent/interface.ts b/packages/wizard/src/agent/interface.ts similarity index 100% rename from packages/cli/src/commands/wizard/agent/interface.ts rename to packages/wizard/src/agent/interface.ts diff --git a/packages/wizard/src/bin/wizard.ts b/packages/wizard/src/bin/wizard.ts new file mode 100644 index 00000000..d6f0be0e --- /dev/null +++ b/packages/wizard/src/bin/wizard.ts @@ -0,0 +1,77 @@ +import { config } from 'dotenv' + +// 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' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +import { run } from '../run.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8'), +) + +const HELP = ` +CipherStash Wizard v${pkg.version} + +Usage: npx @cipherstash/wizard [options] + +The wizard reads your codebase and wires up @cipherstash/stack encryption +for the columns you select. Run it once per project, after \`stash init\`. + +Options: + --help, -h Show help + --version, -v Show version + --debug Print extra diagnostics from the agent +`.trim() + +interface ParsedArgs { + help: boolean + version: boolean + debug: boolean +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2) + const flags = new Set(args) + return { + help: flags.has('--help') || flags.has('-h'), + version: flags.has('--version') || flags.has('-v'), + debug: flags.has('--debug'), + } +} + +async function main() { + const { help, version, debug } = parseArgs(process.argv) + + if (help) { + console.log(HELP) + return + } + + if (version) { + console.log(pkg.version) + return + } + + await run({ + cwd: process.cwd(), + debug, + cliVersion: pkg.version, + }) +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + p.log.error(`Fatal error: ${message}`) + process.exit(1) +}) diff --git a/packages/cli/src/commands/wizard/health-checks/index.ts b/packages/wizard/src/health-checks/index.ts similarity index 100% rename from packages/cli/src/commands/wizard/health-checks/index.ts rename to packages/wizard/src/health-checks/index.ts diff --git a/packages/cli/src/commands/wizard/lib/analytics.ts b/packages/wizard/src/lib/analytics.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/analytics.ts rename to packages/wizard/src/lib/analytics.ts diff --git a/packages/cli/src/commands/wizard/lib/changelog.ts b/packages/wizard/src/lib/changelog.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/changelog.ts rename to packages/wizard/src/lib/changelog.ts diff --git a/packages/cli/src/commands/wizard/lib/constants.ts b/packages/wizard/src/lib/constants.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/constants.ts rename to packages/wizard/src/lib/constants.ts diff --git a/packages/cli/src/commands/wizard/lib/detect.ts b/packages/wizard/src/lib/detect.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/detect.ts rename to packages/wizard/src/lib/detect.ts diff --git a/packages/cli/src/commands/wizard/lib/format.ts b/packages/wizard/src/lib/format.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/format.ts rename to packages/wizard/src/lib/format.ts diff --git a/packages/cli/src/commands/wizard/lib/gather.ts b/packages/wizard/src/lib/gather.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/gather.ts rename to packages/wizard/src/lib/gather.ts diff --git a/packages/cli/src/commands/wizard/lib/install-skills.ts b/packages/wizard/src/lib/install-skills.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/install-skills.ts rename to packages/wizard/src/lib/install-skills.ts diff --git a/packages/cli/src/commands/wizard/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts similarity index 98% rename from packages/cli/src/commands/wizard/lib/post-agent.ts rename to packages/wizard/src/lib/post-agent.ts index 2c325c0d..c53bc2c9 100644 --- a/packages/cli/src/commands/wizard/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -9,7 +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 { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' import type { GatheredContext } from './gather.js' import type { Integration } from './types.js' diff --git a/packages/cli/src/commands/wizard/lib/prerequisites.ts b/packages/wizard/src/lib/prerequisites.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/prerequisites.ts rename to packages/wizard/src/lib/prerequisites.ts diff --git a/packages/wizard/src/lib/rewrite-migrations.ts b/packages/wizard/src/lib/rewrite-migrations.ts new file mode 100644 index 00000000..e6ab0522 --- /dev/null +++ b/packages/wizard/src/lib/rewrite-migrations.ts @@ -0,0 +1,91 @@ +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. drizzle-kit's ALTER COLUMN path wraps the customType + * `dataType()` return value in double-quotes and prepends `"{typeSchema}".`. + * Custom types have no `typeSchema`, so we see several mangled forms + * depending on what `dataType()` returned. We match all of them: + * + * - bare `eql_v2_encrypted` → `"undefined"."eql_v2_encrypted"` + * - pre-quoted `"public"."eql_v2_encrypted"` (stack 0.15.0 regression) → + * `"undefined".""public"."eql_v2_encrypted""` + * - the plain `eql_v2_encrypted` and `"public"."eql_v2_encrypted"` forms, + * in case a future drizzle-kit release stops prepending undefined. + * + * Captures: + * - $1: table name (without quotes) + * - $2: column name (without quotes) + * + * Note: a copy of this lives in `@cipherstash/cli` (`db/rewrite-migrations.ts`) + * because cli's `db install --drizzle` uses the same fix. Both copies are + * tightly coupled to drizzle-kit's output format — if drizzle-kit changes, + * both need to be updated together. + */ +const ALTER_COLUMN_TO_ENCRYPTED_RE = + /ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"undefined"\.""public"\."eql_v2_encrypted""|"undefined"\."eql_v2_encrypted"|"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/wizard: 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/wizard/lib/types.ts b/packages/wizard/src/lib/types.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/types.ts rename to packages/wizard/src/lib/types.ts diff --git a/packages/cli/src/commands/wizard/lib/wire-call-sites.ts b/packages/wizard/src/lib/wire-call-sites.ts similarity index 100% rename from packages/cli/src/commands/wizard/lib/wire-call-sites.ts rename to packages/wizard/src/lib/wire-call-sites.ts diff --git a/packages/cli/src/commands/wizard/run.ts b/packages/wizard/src/run.ts similarity index 100% rename from packages/cli/src/commands/wizard/run.ts rename to packages/wizard/src/run.ts diff --git a/packages/cli/src/commands/wizard/tools/wizard-tools.ts b/packages/wizard/src/tools/wizard-tools.ts similarity index 100% rename from packages/cli/src/commands/wizard/tools/wizard-tools.ts rename to packages/wizard/src/tools/wizard-tools.ts diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json new file mode 100644 index 00000000..56cc3d2f --- /dev/null +++ b/packages/wizard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/wizard/tsup.config.ts b/packages/wizard/tsup.config.ts new file mode 100644 index 00000000..041ddb9f --- /dev/null +++ b/packages/wizard/tsup.config.ts @@ -0,0 +1,28 @@ +import { cpSync, existsSync } from 'node:fs' +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/bin/wizard.ts'], + outDir: 'dist/bin', + format: ['esm'], + platform: 'node', + target: 'es2022', + banner: { + js: `#!/usr/bin/env node +import { createRequire as __createRequire } from 'module'; +var require = __createRequire(import.meta.url);`, + }, + dts: false, + sourcemap: true, + clean: true, + skipNodeModulesBundle: true, + onSuccess: async () => { + // Skills live at the monorepo root and ship inside the wizard tarball so + // the agent can install them into the user's `.claude/skills` directory + // (CIP-2992). The cli used to ship these too — they belong with the + // wizard now. + if (existsSync('../../skills')) { + cpSync('../../skills', 'dist/skills', { recursive: true }) + } + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ead9e3b6..3163ee79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,9 +70,6 @@ importers: packages/cli: dependencies: - '@anthropic-ai/claude-agent-sdk': - specifier: ^0.2.87 - version: 0.2.87(zod@4.3.6) '@cipherstash/auth': specifier: catalog:repo version: 0.36.0 @@ -332,6 +329,49 @@ importers: specifier: catalog:repo version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + packages/wizard: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.87 + version: 0.2.87(zod@4.3.6) + '@cipherstash/auth': + specifier: catalog:repo + version: 0.36.0 + '@clack/prompts': + specifier: 0.10.1 + version: 0.10.1 + dotenv: + specifier: 16.4.7 + version: 16.4.7 + pg: + specifier: 8.13.1 + version: 8.13.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + posthog-node: + specifier: ^5.28.9 + version: 5.28.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/pg': + specifier: ^8.11.11 + version: 8.16.0 + tsup: + specifier: catalog:repo + version: 8.4.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.6.3) + tsx: + specifier: catalog:repo + version: 4.19.3 + typescript: + specifier: catalog:repo + version: 5.6.3 + vitest: + specifier: catalog:repo + version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + packages: '@anthropic-ai/claude-agent-sdk@0.2.87': diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 52d8a05d..28a0985e 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -1,11 +1,11 @@ --- name: stash-cli -description: Configure and use the `@cipherstash/cli` package for EQL database setup, encryption schema management, Supabase integration, and the AI-powered wizard (`npx @cipherstash/cli wizard`). Replaces the legacy `@cipherstash/stack-forge` skill. +description: Configure and use the `@cipherstash/cli` package for project initialization, EQL database setup, encryption schema management, and Supabase integration. Replaces the legacy `@cipherstash/stack-forge` skill. The AI wizard is now a separate package (`@cipherstash/wizard`). --- # CipherStash CLI -Configure and use `@cipherstash/cli` for EQL database setup, encryption schema management, and Supabase integration. (Previously published as `@cipherstash/stack-forge`; the `stash-forge` and `cipherstash-wizard` binaries are now consolidated under `npx @cipherstash/cli`.) +Configure and use `@cipherstash/cli` for project initialization, EQL database setup, encryption schema management, and Supabase integration. Previously published as `@cipherstash/stack-forge`; the `stash-forge` binary is now consolidated under `npx @cipherstash/cli`. The AI-powered wizard formerly bundled here lives in [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard). ## Trigger @@ -18,13 +18,16 @@ Use this skill when: Do NOT trigger when: - The user is working with `@cipherstash/stack` (the runtime SDK) without needing database setup +- The user is running the AI wizard — that's `@cipherstash/wizard`, a separate package - General PostgreSQL questions unrelated to CipherStash ## What is @cipherstash/cli? -`@cipherstash/cli` is a **dev-time CLI and TypeScript library** for managing CipherStash EQL (Encrypted Query Language) in PostgreSQL databases. It is a companion to the `@cipherstash/stack` runtime SDK — it handles database setup during development while `@cipherstash/stack` handles runtime encryption/decryption operations. +`@cipherstash/cli` is a **dev-time CLI and TypeScript library** for managing CipherStash EQL (Encrypted Query Language) in PostgreSQL databases. It is a companion to the `@cipherstash/stack` runtime SDK — it handles project setup and database tooling during development while `@cipherstash/stack` handles runtime encryption/decryption operations. -Think of it like Prisma Migrate or Drizzle Kit: a dev-time tool that manages your database schema. +Think of it like Prisma Migrate or Drizzle Kit: a dev-time tool that prepares your database while the runtime SDK handles queries. + +The binary is named `stash`. Top-level commands: `init`, `auth`, `db`, `schema`, `env`. ## Configuration @@ -39,6 +42,8 @@ export default defineConfig({ }) ``` +`db install` will scaffold this file for you if it's missing. + ### Config options ```typescript @@ -49,112 +54,98 @@ type StashConfig = { ``` - `defineConfig()` provides TypeScript type-checking for the config file. -- `client` points to the encryption client file used by `npx @cipherstash/cli db push` and `npx @cipherstash/cli db validate` to load the encryption schema. +- `client` points to the encryption client file used by `db push` and `db validate` to load the encryption schema. - Config is loaded automatically from `stash.config.ts` by walking up from `process.cwd()` (like `tsconfig.json` resolution). - `.env` files are loaded automatically via `dotenv` before config evaluation. ## CLI Usage -The primary interface is the `@cipherstash/cli` package, run via `npx`: +The primary interface is the `@cipherstash/cli` package, run via `npx` (or your package manager's equivalent runner): ```bash -npx @cipherstash/cli db [options] +npx @cipherstash/cli [options] ``` -### `setup` — Configure database and install EQL extensions - -Interactive wizard that configures your database connection and installs EQL. Run this after `npx @cipherstash/cli init` has set up your encryption schema. +### `init` — Initialize CipherStash for your project ```bash -npx @cipherstash/cli db setup -npx @cipherstash/cli db setup --supabase -npx @cipherstash/cli db setup --force -npx @cipherstash/cli db setup --drizzle +npx @cipherstash/cli init +npx @cipherstash/cli init --supabase +npx @cipherstash/cli init --drizzle ``` -The wizard will: -1. Auto-detect the encryption client file by scanning common locations (`./src/encryption/index.ts`, etc.), then confirm or ask for the path -2. Ask for the database URL (pre-fills from `DATABASE_URL` env var if set) -3. Generate `stash.config.ts` with the database URL and client path -4. Ask to install EQL extensions now -5. If installing, ask which Postgres provider is being used to determine the right install flags: - - **Supabase** — uses `--supabase` (no operator families + Supabase role grants) - - **Neon, Vercel Postgres, PlanetScale, Prisma Postgres** — uses `--exclude-operator-family` - - **AWS RDS, Other / Self-hosted** — standard install -6. Install EQL extensions in the database +Init runs nearly silently, with prompts only when it can't make a sensible default choice: -If `--supabase` is passed as a flag, the provider selection is skipped. +1. **Authenticate** — only prompts when not already logged in (otherwise logs `Using workspace X (region)` and proceeds). +2. **Generate encryption client** — auto-detects your framework (Drizzle from `drizzle.config.*` / `drizzle-orm` / `drizzle-kit` in `package.json`; Supabase from the `DATABASE_URL` host) and silently writes a placeholder client to `./src/encryption/index.ts`. Only prompts you if a file already exists at that path. +3. **Install dependencies** — single combined prompt for `@cipherstash/stack` and `@cipherstash/cli`. Skipped entirely when both are already in `node_modules`. +4. **Print next steps** — points you at `db install` and the optional `@cipherstash/wizard` for AI-guided setup. -**Flags:** -| 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 using the bundled version | - -### `install` — Install EQL extension to the database +The `--supabase` and `--drizzle` flags tailor the intro message and next-steps output. They don't drive prompts — file scaffolding uses the same auto-detection regardless. -Uses bundled SQL by default for offline, deterministic installs. Three SQL variants are bundled: -- `cipherstash-encrypt.sql` — standard install (default) -- `cipherstash-encrypt-supabase.sql` — Supabase-specific variant -- `cipherstash-encrypt-no-operator-family.sql` — no operator family variant +### `auth login` — Authenticate with CipherStash ```bash -# Standard install -npx @cipherstash/cli db install +npx @cipherstash/cli auth login +``` -# Reinstall even if already installed -npx @cipherstash/cli db install --force +Opens a browser-based device code flow and saves a token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running. -# Preview SQL without applying -npx @cipherstash/cli db install --dry-run +### `db install` — Configure the database and install EQL extensions -# Supabase-compatible install (grants anon, authenticated, service_role) +```bash +npx @cipherstash/cli db install npx @cipherstash/cli db install --supabase - -# Skip operator family (for non-superuser database roles) -npx @cipherstash/cli db install --exclude-operator-family - -# Fetch latest from GitHub instead of using bundled SQL -npx @cipherstash/cli db install --latest - -# Generate a Drizzle migration instead of direct install +npx @cipherstash/cli db install --supabase --migration +npx @cipherstash/cli db install --supabase --direct npx @cipherstash/cli db install --drizzle +npx @cipherstash/cli db install --force +``` -# Drizzle migration with custom name and output directory -npx @cipherstash/cli db install --drizzle --name setup-eql --out ./migrations +`db install` is the single command that gets a project from zero to installed EQL: -# Combine flags -npx @cipherstash/cli db install --dry-run --supabase -``` +1. Scaffolds `stash.config.ts` if missing (auto-detects an existing client file at common locations, otherwise prompts). +2. Loads the config. +3. **Safety net:** scaffolds the encryption client file at `config.client` if it doesn't exist (no-op when present). Lets users who skip `init` still end up with a working client file. +4. Detects Supabase (`DATABASE_URL` host) and Drizzle (lockfile / `drizzle-orm` dep) automatically. +5. For Drizzle: generates a Drizzle migration containing the EQL SQL (`drizzle-kit generate --custom --name=...`). +6. For Supabase non-Drizzle: prompts between writing a Supabase migration file and direct install. Pre-selects migration when `supabase/migrations/` exists. +7. Otherwise: installs EQL directly into the database. **Flags:** + | Flag | Description | |------|-------------| | `--force` | Reinstall even if EQL is already installed | -| `--dry-run` | Print the SQL that would be executed without applying it | -| `--supabase` | Use Supabase-compatible install (no operator family + grants to Supabase roles) | +| `--dry-run` | Show what would happen without making changes | +| `--supabase` | Supabase-compatible install (no operator families + grants `anon`, `authenticated`, `service_role`) | | `--exclude-operator-family` | Skip operator family creation (useful for non-superuser roles) | -| `--latest` | Fetch latest EQL from GitHub instead of using the bundled version | | `--drizzle` | Generate a Drizzle migration instead of direct install | +| `--latest` | Fetch latest EQL from GitHub instead of using the bundled version | | `--name ` | Migration name when using `--drizzle` (default: `install-eql`) | | `--out ` | Drizzle output directory when using `--drizzle` (default: `drizzle`) | +| `--migration` | Write the EQL SQL into a Supabase migration file (requires `--supabase`) | +| `--direct` | Run the EQL SQL directly against the database (requires `--supabase`; mutually exclusive with `--migration`) | +| `--migrations-dir ` | Override the Supabase migrations directory (requires `--supabase`; default: `supabase/migrations`) | -#### `install --drizzle` +`--migration`, `--direct`, and `--migrations-dir` only make sense in the Supabase flow and require `--supabase` to be passed explicitly. They never auto-enable `--supabase`. -When `--drizzle` is passed, instead of connecting to the database directly, the CLI: -1. Runs `drizzle-kit generate --custom --name=` to scaffold an empty migration -2. Loads the bundled EQL install SQL (or downloads from GitHub with `--latest`) -3. Writes the SQL into the generated migration file +#### `db install --drizzle` + +When `--drizzle` is passed, the CLI: +1. Runs `drizzle-kit generate --custom --name=` to scaffold an empty migration. +2. Loads the bundled EQL install SQL (or downloads from GitHub with `--latest`). +3. Writes the SQL into the generated migration file. You then run `npx drizzle-kit migrate` to apply it. Requires `drizzle-kit` as a dev dependency. -### `upgrade` — Upgrade EQL extensions +#### `db install --supabase --migration` + +Writes the EQL SQL to `supabase/migrations/00000000000000_cipherstash_eql.sql`. The all-zero timestamp ensures this migration runs before any user migrations that reference `eql_v2_encrypted`. Run `supabase db reset` (local) or `supabase migration up` (remote) to apply it. -Upgrade an existing EQL installation to the version bundled with the package (or latest from GitHub). +Direct-push installs (`--supabase --direct`) do **not** survive `supabase db reset` — the reset drops the database and reruns only files in `supabase/migrations/`. Use `--migration` for projects that use `supabase db reset`. + +### `db upgrade` — Upgrade EQL extensions ```bash npx @cipherstash/cli db upgrade @@ -164,6 +155,7 @@ npx @cipherstash/cli db upgrade --latest ``` **Flags:** + | Flag | Description | |------|-------------| | `--dry-run` | Show what would happen without making changes | @@ -171,11 +163,9 @@ npx @cipherstash/cli db upgrade --latest | `--exclude-operator-family` | Skip operator family creation | | `--latest` | Fetch latest EQL from GitHub instead of bundled | -The EQL install SQL is idempotent and safe to re-run. The command checks the current version, re-runs the install SQL, then reports the new version. If EQL is not installed, it suggests running `npx @cipherstash/cli db install` instead. - -### `validate` — Validate encryption schema +The EQL install SQL is idempotent and safe to re-run. The command checks the current version, re-runs the install SQL, then reports the new version. If EQL is not installed, it suggests running `db install` instead. -Validate your encryption schema for common misconfigurations. +### `db validate` — Validate encryption schema ```bash npx @cipherstash/cli db validate @@ -184,12 +174,14 @@ npx @cipherstash/cli db validate --exclude-operator-family ``` **Flags:** + | Flag | Description | |------|-------------| | `--supabase` | Check for Supabase-specific issues | | `--exclude-operator-family` | Check for issues when operator families are excluded | **Validation rules:** + | Rule | Severity | Description | |------|----------|-------------| | `freeTextSearch` on non-string column | Warning | Free-text search only works with string data | @@ -197,15 +189,9 @@ npx @cipherstash/cli db validate --exclude-operator-family | No indexes on encrypted column | Info | Column is encrypted but not searchable | | `searchableJson` without `json` data type | Error | searchableJson requires `dataType("json")` | -Validation is also automatically run before `push` — issues are logged as warnings but don't block the push. - -The `validateEncryptConfig` function and `reportIssues` helper are exported for programmatic use: - -```typescript -import { validateEncryptConfig, reportIssues } from '@cipherstash/cli' -``` +Validation also runs automatically before `db push` — issues are logged as warnings but don't block the push. -### `push` — Push encryption schema to database (CipherStash Proxy only) +### `db push` — Push encryption schema to the database (CipherStash Proxy only) This command is **only required when using CipherStash Proxy**. If you're using the SDK directly (Drizzle, Supabase, or plain PostgreSQL), this step is not needed — the schema lives in your application code as the source of truth. @@ -215,16 +201,17 @@ npx @cipherstash/cli db push --dry-run ``` **Flags:** + | Flag | Description | |------|-------------| | `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. | When pushing, the CLI: -1. Loads the encryption client from the path in `stash.config.ts` -2. Runs schema validation (warns but doesn't block) -3. Transforms SDK data types to EQL-compatible `cast_as` values (see table below) -4. Connects to Postgres and marks existing `eql_v2_configuration` rows as `inactive` -5. Inserts the new config as an `active` row +1. Loads the encryption client from the path in `stash.config.ts`. +2. Runs schema validation (warns but doesn't block). +3. Transforms SDK data types to EQL-compatible `cast_as` values (see table below). +4. Connects to Postgres and marks existing `eql_v2_configuration` rows as `inactive`. +5. Inserts the new config as an `active` row. **SDK to EQL type mapping:** @@ -238,29 +225,52 @@ When pushing, the CLI: | `date` | `date` | | `json` | `jsonb` | -### `status` — Show EQL installation status +### `db status` — Show EQL installation status ```bash npx @cipherstash/cli db status ``` Reports: -- Whether EQL is installed and which version -- Database permission status -- Whether an active encrypt config exists in `eql_v2_configuration` (only relevant for CipherStash Proxy) +- Whether EQL is installed and which version. +- Database permission status. +- Whether an active encrypt config exists in `eql_v2_configuration` (only relevant for CipherStash Proxy). -### `test-connection` — Test database connectivity +### `db test-connection` — Test database connectivity ```bash npx @cipherstash/cli db test-connection ``` -Verifies the database URL in your config is valid and the database is reachable. Reports: -- Database name -- Connected user/role -- PostgreSQL server version +Verifies the database URL in your config is valid and the database is reachable. Reports the database name, connected role, and PostgreSQL server version. Useful for debugging connection issues before running `db install`. + +### `db migrate` — Run pending encrypt config migrations -Useful for debugging connection issues before running `install` or other commands. +```bash +npx @cipherstash/cli db migrate +``` + +Not yet implemented — placeholder for future encrypt-config migration tooling. + +### `schema build` — Generate an encryption client from your database + +```bash +npx @cipherstash/cli schema build +npx @cipherstash/cli schema build --supabase +``` + +Connects to your database, lets you select tables and columns to encrypt, asks about searchable indexes, and generates a typed encryption client file. Reads `databaseUrl` from `stash.config.ts`. + +For AI-guided schema integration that edits your existing schema files in place, run `npx @cipherstash/wizard` instead — it's a separate package designed for that workflow. + +### `env` — Print production env vars for deployment + +```bash +npx @cipherstash/cli env +npx @cipherstash/cli env --write +``` + +Experimental. Prints the environment variables (`CS_*`) you need to deploy a CipherStash-backed app. With `--write`, writes them into a `.env.production` file. ## Programmatic API @@ -272,10 +282,6 @@ Identity function that provides type-safe configuration for `stash.config.ts`. Finds and loads `stash.config.ts` from the current directory or any parent. Validates with Zod. Applies defaults (e.g. `client` defaults to `'./src/encryption/index.ts'`). Exits with code 1 if config is missing or invalid. -### `loadEncryptConfig(clientPath: string): Promise` - -Loads the encryption client file, extracts the encrypt config, and returns it. Used by `push` and `validate` to build the schema JSON. - ### `loadBundledEqlSql(options?): string` Load the bundled EQL install SQL as a string: @@ -313,7 +319,7 @@ type PermissionCheckResult = { Required permissions (one of): - `SUPERUSER` role (sufficient for everything), OR -- `CREATE` privilege on database + `CREATE` privilege on public schema +- `CREATE` privilege on database + `CREATE` privilege on `public` schema - If `pgcrypto` is not installed: also needs `SUPERUSER` or `CREATEDB` #### `installer.isInstalled(): Promise` @@ -365,18 +371,21 @@ if (await installer.isInstalled()) { - Node.js >= 22 - PostgreSQL database with sufficient permissions (see `checkPermissions()`) -- A `stash.config.ts` file with a valid `databaseUrl` +- A `stash.config.ts` file with a valid `databaseUrl` (or run `db install` to scaffold it) - Peer dependency: `@cipherstash/stack` >= 0.6.0 ## Common issues ### Permission errors during install + The database role needs `CREATE` privileges on the database and public schema, or `SUPERUSER`. Run `checkPermissions()` or check the CLI output for details on what's missing. ### Config not found -`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. + +`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. Or run `npx @cipherstash/cli db install` to scaffold it. ### Supabase environments + Always use `--supabase` (or `supabase: true` programmatically) when targeting Supabase. This uses a compatible install script and grants permissions to `anon`, `authenticated`, and `service_role` roles. ### Operator families and ORDER BY @@ -386,3 +395,9 @@ When EQL is installed with `--supabase` or `--exclude-operator-family`, PostgreS Sort application-side after decrypting the results as a workaround. Operator family support for Supabase is being developed with the Supabase and CipherStash teams and will be available in a future release. This limitation applies to any database environment where operator families are not installed. + +## Related skills + +- **`@cipherstash/wizard`** — AI-guided encryption setup. Reads your codebase, asks which columns to encrypt, edits your schema and call sites in place. Run with `npx @cipherstash/wizard`. Separate package from this CLI. +- **`stash-encryption`** — Defines encrypted schemas and uses `Encryption()` / `encryptModel` / `decryptModel` at runtime via `@cipherstash/stack`. +- **`stash-drizzle`** / **`stash-supabase`** — Drizzle and Supabase integrations.