diff --git a/.changeset/cli-init-agent-handoff.md b/.changeset/cli-init-agent-handoff.md new file mode 100644 index 00000000..513248f1 --- /dev/null +++ b/.changeset/cli-init-agent-handoff.md @@ -0,0 +1,37 @@ +--- +'stash': minor +--- + +`stash init` can now hand off the rest of setup to whichever coding agent the user is set up with — and it leaves them with a project-specific action plan and the right reference material, not just generic rules. + +The new pipeline: + +1. **Authenticate** (unchanged). +2. **Resolve `DATABASE_URL`** — uses the same resolver as `stash db install` (flag → env → `supabase status` → interactive prompt). Hard-fails with an actionable message if nothing resolves. +3. **Build the encryption client.** When the database has tables, `init` introspects them and generates a real client from the user's selection. When the database is empty, it falls back to a placeholder so fresh projects still work — and the action prompt notes the placeholder so the agent reshapes it later. +4. **Install dependencies** — `@cipherstash/stack` (runtime) + `stash` (CLI dev dep). +5. **Install EQL into the database** — y/N confirm, then runs `stash db install` programmatically against the URL we already resolved. No second prompt for credentials. +6. **Pick a handoff** from the four-option menu. Each handoff installs the right artifacts for the chosen tool: + - **Hand off to Claude Code** — copies the per-integration set of authored skills (`stash-encryption` + `stash-` + `stash-cli`) into `.claude/skills/`, writes `.cipherstash/context.json` and `.cipherstash/setup-prompt.md`, spawns `claude`. Default when `claude` is on PATH. + - **Hand off to Codex** — writes a sentinel-managed `AGENTS.md` (durable doctrine) + copies the same skills into `.codex/skills/` (procedural workflows), writes `context.json` + `setup-prompt.md`, spawns `codex`. Default when `codex` is on PATH and `claude` is not. Follows OpenAI's Codex guidance: AGENTS.md for repo doctrine, skills for repeatable workflows. + - **Use the CipherStash Agent** — writes `context.json` and runs `stash wizard`. Fallback for users without a local CLI agent. The wizard installs its own skills. + - **Write AGENTS.md** — for editor agents (Cursor, Windsurf, Cline) that don't auto-load skill directories. Writes a single `AGENTS.md` with the doctrine *plus* the relevant skill content inlined under a sentinel block, so the agent has the API details without needing to follow file references. Plus `context.json` + `setup-prompt.md`. No spawn. + +Detection is non-blocking: if the chosen CLI agent (`claude` or `codex`) isn't installed, init still writes the artifacts and prints install + manual-launch instructions. Progress is never wasted. + +`.cipherstash/setup-prompt.md` is the headline artifact. It's the project-specific action plan — *"init has done X and Y; you need to do Z next, with these exact commands and paths"* — generated from the current init state. The launch prompt for Claude / Codex points the agent at this file first; the installed skills provide the reusable rulebook the prompt references. For IDE users, it's ready to paste into the first chat. + +Per-integration skill subset: + +``` +drizzle → stash-encryption + stash-drizzle + stash-cli +supabase → stash-encryption + stash-supabase + stash-cli +postgresql → stash-encryption + stash-cli +dynamodb → stash-encryption + stash-dynamodb + stash-cli +``` + +The skills themselves are the authored ones at the repo root (`/skills/`); they ship inside the CLI tarball via `tsup` so init can copy them locally without a network round-trip. The AGENTS.md doctrine fragment ships the same way. + +Re-running `init` is safe — `AGENTS.md` uses sentinel-marker upsert (``), so the managed region is replaced in place and any user edits outside it are preserved. Skill directories are overwritten so the user always gets the latest content. `setup-prompt.md` is regenerated wholesale each run since it's meant to reflect the current state. + +`.cipherstash/context.json` is the universal "what shape is this project" payload — integration, encryption client path, schema, env key names (never values), package manager, install command, CLI version, names of installed skills, generation timestamp. diff --git a/packages/cli/README.md b/packages/cli/README.md index 913172b7..7cdbac9e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,17 +12,19 @@ The single CLI for CipherStash. It handles authentication, project initializatio ```bash npm install -D stash npx stash auth login # authenticate with CipherStash -npx stash init # scaffold encryption schema and install dependencies -npx stash db install # scaffold stash.config.ts (if missing) and install EQL +npx stash init # scaffold, introspect, install EQL, hand off to your agent ``` -What each step does: +`stash init` runs the whole setup as one flow: authenticate, resolve `DATABASE_URL`, introspect your database and let you pick which columns to encrypt, install dependencies, install the EQL extension, and finish by handing off to your local coding agent. At the end it presents a four-option menu: -- `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 `stash` 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. +- **Hand off to Claude Code** — installs a project-local skill at `.claude/skills/cipherstash-setup/SKILL.md`, then launches `claude` interactively. +- **Hand off to Codex** — writes `AGENTS.md` at the project root, then launches `codex`. +- **Use the CipherStash Agent** — runs the in-house wizard (`@cipherstash/wizard`). +- **Write AGENTS.md** — writes the rules file and stops, for Cursor / Windsurf / Cline / any AGENTS.md-aware tool. -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. +A project-specific action plan is written to `.cipherstash/setup-prompt.md` regardless of which option you pick — it tells the agent exactly what's already done and what's left, with the right commands for your package manager and ORM. The matching context (selected columns, env keys, paths, versions) is at `.cipherstash/context.json`. + +If neither `claude` nor `codex` is on PATH, init still writes the rules files and prints install instructions — your progress is never wasted. --- @@ -30,14 +32,12 @@ After `db install`, declare which columns to encrypt — either run [`@ciphersta ``` npx stash auth login - └── npx stash init - └── npx stash db install - └── npx @cipherstash/wizard ← fast path: AI edits your files - OR - Edit schema files by hand ← escape hatch + └── npx stash init ← introspects DB, installs EQL, hands off to your agent + └── Agent edits schema files / generates migrations + └── npx stash db push ← when ready to roll out further changes ``` -`stash` 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. +`stash` covers authentication, initialization, EQL install/upgrade/validate/push/migrate, schema introspection, and a `stash wizard` subcommand that thin-wraps [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard). The wizard package itself is a separate npm install — kept out of the `stash` bundle so the agent SDK doesn't bloat the CLI. --- @@ -69,7 +69,7 @@ Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db push`, ### `npx stash init` -Scaffold CipherStash for your project. Generates an encryption client file, writes initial schema code, and installs `stash` as a dev dependency. +Set up CipherStash end-to-end: authenticate, introspect your database, install dependencies, install EQL, and hand off the rest to your local coding agent. ```bash npx stash init [--supabase] [--drizzle] @@ -80,7 +80,18 @@ npx stash 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 stash db install`, then edit your encryption client file directly. +What `init` does, in order: + +1. **Authenticate** — re-uses an existing token if found, otherwise opens the browser device-code flow. +2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `db install` uses. +3. **Generate the encryption client** — connects to your database, lists tables, and prompts you to multi-select which columns to encrypt. Writes `./src/encryption/index.ts` with the right shape for the detected ORM (Drizzle / Supabase / plain Postgres). Falls back to a placeholder if the database has no tables yet. +4. **Install dependencies** — `@cipherstash/stack` (runtime) and `stash` (dev), with a confirmation prompt. +5. **Install EQL** — runs `stash db install` against the resolved URL after a y/N confirm. +6. **Hand off** — four-option menu (Claude Code / Codex / CipherStash Agent / write `AGENTS.md`). See the Quickstart section above for what each option writes and spawns. + +The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` tells whichever agent picks up next what's already done and what's left. + +`CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for the rulebook fetch. Useful for local-dev against a wizard gateway running on `localhost`. --- @@ -96,6 +107,18 @@ Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check --- +### `npx stash wizard` + +Launch the CipherStash AI wizard. Thin wrapper around [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard) — the wizard ships as a separate npm package so the agent SDK stays out of the `stash` bundle, but you don't need to remember a second tool name. + +```bash +npx stash wizard [...flags] +``` + +Any flags after `wizard` are forwarded verbatim to the wizard package. On the first run the package manager downloads the wizard (~5s); subsequent runs are instant. + +--- + ### `npx stash secrets` Manage end-to-end encrypted secrets. diff --git a/packages/cli/package.json b/packages/cli/package.json index 99245df7..614a98af 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,6 +7,7 @@ "files": [ "dist", "dist/sql", + "dist/rulebook", "README.md", "LICENSE", "CHANGELOG.md" diff --git a/packages/cli/src/commands/init/__tests__/detect-agents.test.ts b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts new file mode 100644 index 00000000..a10954d6 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/detect-agents.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { detectAgents, shouldOfferClaudeCode } from '../detect-agents.js' + +describe('detectAgents', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'detect-agents-test-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('reports no project artifacts in a fresh directory', () => { + const env = detectAgents(tmp, {}) + expect(env.project.claudeDir).toBe(false) + expect(env.project.claudeMd).toBe(false) + expect(env.project.claudeSkillsDir).toBe(false) + expect(env.project.agentsMd).toBe(false) + }) + + it('detects CLAUDE.md, .claude/, and .claude/skills/', () => { + writeFileSync(join(tmp, 'CLAUDE.md'), 'hi') + mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true }) + + const env = detectAgents(tmp, {}) + expect(env.project.claudeMd).toBe(true) + expect(env.project.claudeDir).toBe(true) + expect(env.project.claudeSkillsDir).toBe(true) + }) + + it('detects AGENTS.md at the project root', () => { + writeFileSync(join(tmp, 'AGENTS.md'), '# project rules\n') + const env = detectAgents(tmp, {}) + expect(env.project.agentsMd).toBe(true) + }) + + it('exposes both claudeCode and codex as boolean fields on cli', () => { + const env = detectAgents(tmp, {}) + expect(typeof env.cli.claudeCode).toBe('boolean') + expect(typeof env.cli.codex).toBe('boolean') + }) + + it('classifies the editor from env signals', () => { + expect(detectAgents(tmp, { CURSOR_TRACE_ID: 'abc' }).editor).toBe('cursor') + expect(detectAgents(tmp, { TERM_PROGRAM: 'vscode' }).editor).toBe('vscode') + expect(detectAgents(tmp, {}).editor).toBe('unknown') + }) + + it('shouldOfferClaudeCode follows CLI presence', () => { + const env = detectAgents(tmp, {}) + // We can't reliably mock command -v from a unit test, so just assert the + // helper reads the field without throwing. + expect(typeof shouldOfferClaudeCode(env)).toBe('boolean') + expect(shouldOfferClaudeCode(env)).toBe(env.cli.claudeCode) + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts b/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts new file mode 100644 index 00000000..182814c3 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/sentinel-upsert.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + SENTINEL_END, + SENTINEL_START, + upsertManagedBlock, +} from '../lib/sentinel-upsert.js' + +describe('upsertManagedBlock', () => { + const managed = 'rule one\nrule two' + + it('creates a wrapped block when file is missing', () => { + const result = upsertManagedBlock({ managed }) + expect(result).toContain(SENTINEL_START) + expect(result).toContain(SENTINEL_END) + expect(result).toContain('rule one') + expect(result).toContain('rule two') + }) + + it('replaces only the managed region on re-run', () => { + const initial = upsertManagedBlock({ managed: 'old rule' }) + const wrapped = `# user header\n\n${initial}\n# user footer\n` + + const next = upsertManagedBlock({ existing: wrapped, managed: 'new rule' }) + expect(next).toContain('# user header') + expect(next).toContain('# user footer') + expect(next).toContain('new rule') + expect(next).not.toContain('old rule') + }) + + it('appends managed block when sentinels absent', () => { + const existing = '# pre-existing CLAUDE.md content\n' + const result = upsertManagedBlock({ existing, managed }) + expect(result.startsWith('# pre-existing CLAUDE.md content')).toBe(true) + expect(result).toContain(SENTINEL_START) + }) + + it('throws on a malformed sentinel pair', () => { + const broken = `${SENTINEL_END}\nstuff\n${SENTINEL_START}\n` + expect(() => upsertManagedBlock({ existing: broken, managed })).toThrow( + /malformed/i, + ) + }) + + it('throws when only one sentinel is present', () => { + const orphan = `intro\n${SENTINEL_START}\nstuff\n` + expect(() => upsertManagedBlock({ existing: orphan, managed })).toThrow( + /malformed/i, + ) + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/utils.test.ts b/packages/cli/src/commands/init/__tests__/utils.test.ts index 1e331deb..83c5afcd 100644 --- a/packages/cli/src/commands/init/__tests__/utils.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils.test.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { detectPackageManager, devInstallCommand, + isPackageInstalled, prodInstallCommand, runnerCommand, } from '../utils.js' @@ -166,3 +167,44 @@ describe('runnerCommand', () => { ) }) }) + +describe('isPackageInstalled', () => { + let tmp: string + let cwdSpy: ReturnType + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'isinstalled-test-')) + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp) + }) + + afterEach(() => { + cwdSpy.mockRestore() + rmSync(tmp, { recursive: true, force: true }) + }) + + it('returns false when node_modules/ does not exist', () => { + expect(isPackageInstalled('stash')).toBe(false) + }) + + it('returns true when node_modules//package.json exists', () => { + const pkgDir = join(tmp, 'node_modules', 'stash') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), '{"name":"stash"}') + expect(isPackageInstalled('stash')).toBe(true) + }) + + it('returns false when the directory exists but no package.json', () => { + // The bug we fixed: a leftover dir from an aborted install or stale + // workspace symlink would previously be treated as a real install. + const pkgDir = join(tmp, 'node_modules', 'stash') + mkdirSync(pkgDir, { recursive: true }) + expect(isPackageInstalled('stash')).toBe(false) + }) + + it('handles scoped package names', () => { + const pkgDir = join(tmp, 'node_modules', '@cipherstash', 'stack') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), '{"name":"@cipherstash/stack"}') + expect(isPackageInstalled('@cipherstash/stack')).toBe(true) + }) +}) diff --git a/packages/cli/src/commands/init/detect-agents.ts b/packages/cli/src/commands/init/detect-agents.ts new file mode 100644 index 00000000..6df2d7f9 --- /dev/null +++ b/packages/cli/src/commands/init/detect-agents.ts @@ -0,0 +1,102 @@ +import { existsSync, statSync } from 'node:fs' +import { delimiter, resolve } from 'node:path' +import { platform } from 'node:os' + +export type Editor = 'vscode' | 'cursor' | 'unknown' + +export interface AgentEnvironment { + cli: { + /** `claude` is on PATH. */ + claudeCode: boolean + /** `codex` is on PATH. */ + codex: boolean + } + project: { + /** A `.claude/` directory exists at the project root. */ + claudeDir: boolean + /** A `CLAUDE.md` file exists at the project root. */ + claudeMd: boolean + /** A `.claude/skills/` directory exists at the project root. */ + claudeSkillsDir: boolean + /** An `AGENTS.md` file exists at the project root. */ + agentsMd: boolean + } + /** Which editor is hosting the current terminal, if recognisable. */ + editor: Editor +} + +/** + * Walk `PATH` looking for an executable. Pure-Node lookup so we don't + * depend on `/bin/sh -c command -v` (POSIX-only) or `where` (Windows-only). + * Allowlists the bin name to a conservative pattern — a defensive + * belt-and-braces given callers only pass closed-enum literals today. + */ +function isOnPath(bin: string, env: NodeJS.ProcessEnv): boolean { + if (!/^[a-z0-9_-]+$/i.test(bin)) return false + const path = env.PATH ?? env.Path ?? env.path ?? '' + if (!path) return false + + const isWindows = platform() === 'win32' + // PATHEXT lets us match `claude.cmd` / `claude.exe` on Windows; on POSIX we + // only look for the bare name. We don't honour `process.env.PATHEXT` for + // arbitrary user-set casing — `.cmd`, `.exe`, `.bat` cover ~99% of installs. + const exts = isWindows ? ['.cmd', '.exe', '.bat', ''] : [''] + + for (const dir of path.split(delimiter)) { + if (!dir) continue + for (const ext of exts) { + const candidate = resolve(dir, `${bin}${ext}`) + if (existsSync(candidate)) return true + } + } + return false +} + +function detectEditor(env: NodeJS.ProcessEnv): Editor { + if (env.CURSOR_TRACE_ID) return 'cursor' + if (env.TERM_PROGRAM === 'vscode') return 'vscode' + return 'unknown' +} + +function isDirectory(path: string): boolean { + if (!existsSync(path)) return false + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** + * Detect available coding agents and editor context. + * + * `cwd` and `env` are injected so tests can mock them; production callers can + * use the no-arg form. + */ +export function detectAgents( + cwd: string = process.cwd(), + env: NodeJS.ProcessEnv = process.env, +): AgentEnvironment { + return { + cli: { + claudeCode: isOnPath('claude', env), + codex: isOnPath('codex', env), + }, + project: { + claudeDir: isDirectory(resolve(cwd, '.claude')), + claudeMd: existsSync(resolve(cwd, 'CLAUDE.md')), + claudeSkillsDir: isDirectory(resolve(cwd, '.claude', 'skills')), + agentsMd: existsSync(resolve(cwd, 'AGENTS.md')), + }, + editor: detectEditor(env), + } +} + +/** + * Convenience predicate. The handoff offer in the init flow wants to know + * "should we default to Claude Code?", which collapses CLI presence + any + * project-level Claude artifact into a single yes/no. + */ +export function shouldOfferClaudeCode(env: AgentEnvironment): boolean { + return env.cli.claudeCode +} diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md new file mode 100644 index 00000000..b5027c43 --- /dev/null +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -0,0 +1,48 @@ +# CipherStash + +This project uses [CipherStash](https://cipherstash.com) for searchable, field-level encryption. Plaintext values are encrypted client-side via `@cipherstash/stack` before they leave the application; ciphertext is stored as `jsonb` in Postgres (or as encrypted attributes in DynamoDB) and decrypted on read. + +This document is the **durable rule book** for any agent working on this codebase. Read it before touching encryption-related code. Repeatable workflows (setup, schema design, migrations) live in skills installed alongside this file — see "Where to read more" at the bottom. + +## What you are working with + +- **Encryption client** — a per-project module that defines which tables and columns are encrypted, what data type each column holds, and which search operations are enabled. The path is in `.cipherstash/context.json` under `encryptionClientPath`. +- **EQL extension** — a Postgres extension installed via `stash db install` that provides server-side functions for searchable encryption (`eql_v2.*`). Required before any migration that creates encrypted columns. +- **Project context** — `.cipherstash/context.json` records what `stash init` discovered: integration, package manager, env key names (never values), schemas, install command, CLI version. Treat it as authoritative. +- **Action plan** — `.cipherstash/setup-prompt.md` is the project-specific to-do list for the current setup run. Read it first. + +## Invariants — never break these + +1. **Encrypted columns are nullable `jsonb`.** Never declare them as `text`, `varchar`, `bytea`, or any plaintext type. Never mark them `NOT NULL` at creation — the application writes ciphertext after the column exists, and a `NOT NULL` constraint will break inserts. (DynamoDB equivalent: encrypted attributes are written as the SDK's encrypted-blob shape; do not invent your own scheme.) +2. **Never log plaintext.** Do not add `console.log`, `logger.info`, or test-fixture dumps that print decrypted values. Sensitive fields stay in memory only as long as the request needs them. Encrypted blobs are also not for logs — they reveal which records were touched. +3. **Never read or echo secrets.** Env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) are fine to reference in code and docs. Their *values* are not. New env keys go in `.env.example` with placeholders; instruct the user to add the real value locally. +4. **Never invent CipherStash APIs.** If you don't know how a function is called, read the relevant skill (see below) — don't guess. The TypeScript types in `@cipherstash/stack` are the source of truth for what's callable. +5. **Never run database introspection yourself.** Don't run `psql`, `\d`, `pg_dump`, `supabase db dump`, or `drizzle-kit introspect`. The CLI already did this; the result is in `context.json`. If you need fresh introspection, ask the user to re-run `stash init`. +6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash db upgrade`, not a hand-edit). + +## Migrations — three phases, always reversible + +Encryption migrations affect production data. Treat every change as **plan → implement → verify**. + +**Phase 1 — Plan.** Identify the candidate encrypted fields. Identify the migration tool already in use (Drizzle Kit, Supabase CLI, Prisma migrate, raw SQL). Produce a written migration plan including the rollback. Ask the user before applying anything that would change existing data. + +**Phase 2 — Implement.** Add the encrypted column alongside the plaintext column (suffix `_encrypted` while both exist). Write a backfill script that reads the plaintext, encrypts via the encryption client, and writes the ciphertext. Keep the plaintext column readable during the transition; do not drop it in the same migration as the cutover. + +**Phase 3 — Verify.** Run the migration in a dev or test database first. Confirm the round-trip: insert through the encryption client, select back, assert the value decrypts. Confirm the search operations declared on each column actually work. Only then propose dropping the plaintext column — and that's a separate migration, not the same one. + +## Stop and ask the user when + +- The context file is missing, stale, or disagrees with the encryption client. +- A column targeted for encryption already has plaintext rows — that's a backfill plan, not a rename. +- The repo already has partial CipherStash setup that doesn't match `context.json` — someone else may have run `stash init` with different choices. +- A migration would change the data type of a column the user has already filled. +- You are about to delete or rename a file the user did not mention. + +## Where to read more + +The CipherStash setup skills are installed alongside this file. Use them when you need API details — don't re-invent. Likely paths: + +- `.claude/skills//SKILL.md` (Claude Code) +- `.codex/skills//SKILL.md` (Codex) + +Skills relevant to this project depend on the integration. Common ones: `stash-encryption` (encryption API), `stash-cli` (`stash` commands), and one of `stash-drizzle` / `stash-supabase` / `stash-dynamodb` for the chosen ORM. diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 4e395c50..20c79696 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -4,8 +4,12 @@ import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' -import { installForgeStep } from './steps/install-forge.js' +import { gatherContextStep } from './steps/gather-context.js' +import { howToProceedStep } from './steps/how-to-proceed.js' +import { installDepsStep } from './steps/install-deps.js' +import { installEqlStep } from './steps/install-eql.js' import { nextStepsStep } from './steps/next-steps.js' +import { resolveDatabaseStep } from './steps/resolve-database.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' @@ -16,8 +20,12 @@ const PROVIDER_MAP: Record InitProvider> = { const STEPS = [ authenticateStep, + resolveDatabaseStep, buildSchemaStep, - installForgeStep, + installDepsStep, + installEqlStep, + gatherContextStep, + howToProceedStep, nextStepsStep, ] diff --git a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts new file mode 100644 index 00000000..e4ad76e2 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentsMdBody } from '../build-agents-md.js' + +const SENTINEL_START = '' +const SENTINEL_END = '' + +describe('buildAgentsMdBody', () => { + it('wraps the body in the rulebook sentinel pair', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-only') + expect(out.startsWith(SENTINEL_START)).toBe(true) + expect(out.trimEnd().endsWith(SENTINEL_END)).toBe(true) + }) + + it('doctrine-only includes the durable doctrine but no skill content', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-only') + expect(out).toContain('# CipherStash') + // Doctrine references invariants — pick a stable phrase that's unlikely + // to drift across rewrites. + expect(out).toMatch(/Never log plaintext/) + // Inlined skill markers should NOT appear. + expect(out).not.toContain('# Skill: stash-encryption') + expect(out).not.toContain('# Skill: stash-drizzle') + }) + + it('doctrine-plus-skills inlines the per-integration skills', () => { + const out = buildAgentsMdBody('drizzle', 'doctrine-plus-skills') + expect(out).toContain('# CipherStash') + expect(out).toContain('# Skill: stash-encryption') + expect(out).toContain('# Skill: stash-drizzle') + expect(out).toContain('# Skill: stash-cli') + // Frontmatter from individual skill files should be stripped — the + // `name: ` line is part of YAML frontmatter and should not leak. + expect(out).not.toMatch(/^---\nname: stash-encryption/m) + }) + + it('inlines a different skill set per integration', () => { + const drizzleOut = buildAgentsMdBody('drizzle', 'doctrine-plus-skills') + const supabaseOut = buildAgentsMdBody('supabase', 'doctrine-plus-skills') + + expect(drizzleOut).toContain('# Skill: stash-drizzle') + expect(drizzleOut).not.toContain('# Skill: stash-supabase') + + expect(supabaseOut).toContain('# Skill: stash-supabase') + expect(supabaseOut).not.toContain('# Skill: stash-drizzle') + }) + + it('postgresql integration omits ORM-specific skills', () => { + const out = buildAgentsMdBody('postgresql', 'doctrine-plus-skills') + expect(out).toContain('# Skill: stash-encryption') + expect(out).toContain('# Skill: stash-cli') + expect(out).not.toContain('# Skill: stash-drizzle') + expect(out).not.toContain('# Skill: stash-supabase') + expect(out).not.toContain('# Skill: stash-dynamodb') + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts new file mode 100644 index 00000000..362cec38 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + SKILL_MAP, + installSkills, + readBundledSkill, +} from '../install-skills.js' + +describe('SKILL_MAP', () => { + it('always includes stash-encryption and stash-cli for every integration', () => { + for (const [integration, skills] of Object.entries(SKILL_MAP)) { + expect(skills, integration).toContain('stash-encryption') + expect(skills, integration).toContain('stash-cli') + } + }) + + it('drizzle includes stash-drizzle', () => { + expect(SKILL_MAP.drizzle).toContain('stash-drizzle') + }) + + it('supabase includes stash-supabase', () => { + expect(SKILL_MAP.supabase).toContain('stash-supabase') + }) + + it('dynamodb includes stash-dynamodb', () => { + expect(SKILL_MAP.dynamodb).toContain('stash-dynamodb') + }) + + it('postgresql skips ORM-specific skills', () => { + expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle') + expect(SKILL_MAP.postgresql).not.toContain('stash-supabase') + expect(SKILL_MAP.postgresql).not.toContain('stash-dynamodb') + }) +}) + +describe('installSkills', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'install-skills-test-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('copies the per-integration skills into destDir', () => { + const copied = installSkills(tmp, '.claude/skills', 'drizzle') + expect(copied).toEqual(['stash-encryption', 'stash-drizzle', 'stash-cli']) + for (const name of copied) { + expect( + existsSync(join(tmp, '.claude/skills', name, 'SKILL.md')), + `${name}/SKILL.md should be present`, + ).toBe(true) + } + }) + + it('honours the destDir parameter (codex)', () => { + const copied = installSkills(tmp, '.codex/skills', 'supabase') + expect(copied).toContain('stash-supabase') + expect(existsSync(join(tmp, '.codex/skills/stash-supabase/SKILL.md'))).toBe( + true, + ) + // Does not write to .claude/ when codex is the target. + expect(existsSync(join(tmp, '.claude'))).toBe(false) + }) + + it('is idempotent — re-running does not throw and yields the same result', () => { + const first = installSkills(tmp, '.claude/skills', 'postgresql') + const second = installSkills(tmp, '.claude/skills', 'postgresql') + expect(second).toEqual(first) + }) + + it('writes SKILL.md content from the bundled source', () => { + installSkills(tmp, '.claude/skills', 'drizzle') + const content = readFileSync( + join(tmp, '.claude/skills/stash-encryption/SKILL.md'), + 'utf-8', + ) + expect(content).toMatch(/^---/) + expect(content).toContain('name: stash-encryption') + }) +}) + +describe('readBundledSkill', () => { + it('returns the SKILL.md body for a bundled skill', () => { + const body = readBundledSkill('stash-encryption') + expect(body).toBeDefined() + expect(body).toContain('name: stash-encryption') + }) + + it('returns undefined for an unknown skill name', () => { + expect(readBundledSkill('does-not-exist')).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts new file mode 100644 index 00000000..a9acf3dc --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { type SetupPromptContext, renderSetupPrompt } from '../setup-prompt.js' + +const baseCtx: SetupPromptContext = { + integration: 'drizzle', + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm', + schemaFromIntrospection: false, + eqlInstalled: false, + stackInstalled: false, + cliInstalled: false, + handoff: 'claude-code', + installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], +} + +describe('renderSetupPrompt', () => { + it('emits integration + package manager in the header', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toContain('Integration: drizzle') + expect(out).toContain('Package manager: pnpm') + // The rulebook version line is gone — the rulebook package no longer exists. + expect(out).not.toMatch(/Rulebook version:/) + }) + + it('marks placeholder schema as a TODO when not from introspection', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toMatch(/PLACEHOLDER schema/) + expect(out).toMatch(/Reshape the encryption client/) + }) + + it('drops the reshape TODO when schema came from introspection', () => { + const out = renderSetupPrompt({ + ...baseCtx, + schemaFromIntrospection: true, + }) + expect(out).toMatch(/sourced from live database introspection/) + expect(out).not.toMatch(/Reshape the encryption client/) + }) + + it('lists EQL install as a TODO when not installed', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toMatch(/Install EQL into the database/) + }) + + it('drops the EQL install TODO when already installed', () => { + const out = renderSetupPrompt({ ...baseCtx, eqlInstalled: true }) + expect(out).toMatch(/Installed the EQL extension/) + expect(out).not.toMatch(/Install EQL into the database/) + }) + + it('emits drizzle-kit commands for drizzle integration', () => { + const out = renderSetupPrompt(baseCtx) + expect(out).toContain('pnpm exec drizzle-kit generate') + expect(out).toContain('pnpm exec drizzle-kit migrate') + }) + + it('emits supabase migration commands for supabase integration', () => { + const out = renderSetupPrompt({ + ...baseCtx, + integration: 'supabase', + installedSkills: ['stash-encryption', 'stash-supabase', 'stash-cli'], + }) + expect(out).toContain('supabase migration new') + expect(out).toContain('encryptedSupabase') + }) + + it('uses the right runner per package manager', () => { + const npm = renderSetupPrompt({ ...baseCtx, packageManager: 'npm' }) + const bun = renderSetupPrompt({ ...baseCtx, packageManager: 'bun' }) + const yarn = renderSetupPrompt({ ...baseCtx, packageManager: 'yarn' }) + + expect(npm).toContain('npx --no-install drizzle-kit generate') + expect(bun).toContain('bun x drizzle-kit generate') + expect(yarn).toContain('yarn drizzle-kit generate') + }) + + it('points each handoff at the right rule source', () => { + const claude = renderSetupPrompt({ ...baseCtx, handoff: 'claude-code' }) + const codex = renderSetupPrompt({ ...baseCtx, handoff: 'codex' }) + const agents = renderSetupPrompt({ ...baseCtx, handoff: 'agents-md' }) + + expect(claude).toContain('.claude/skills/') + expect(claude).toContain('`stash-encryption`') + expect(codex).toContain('AGENTS.md') + expect(codex).toContain('.codex/skills/') + expect(agents).toContain('AGENTS.md') + expect(agents).not.toContain('.claude/skills/') + expect(agents).not.toContain('.codex/skills/') + }) +}) diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts new file mode 100644 index 00000000..692a605c --- /dev/null +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } 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' +import { SKILL_MAP, readBundledSkill } from './install-skills.js' + +/** Sentinel pair so re-runs replace only our region in the user's file. */ +const SENTINEL_START = '' +const SENTINEL_END = '' + +export type AgentsMdMode = 'doctrine-only' | 'doctrine-plus-skills' + +/** + * Render the managed body of `AGENTS.md` (the bit that goes inside the + * sentinel block — the caller is responsible for the upsert). + * + * doctrine-only — the durable AGENTS.md doctrine file. Used by + * the Codex handoff, where workflows live in + * `.codex/skills/` and AGENTS.md is reserved for + * durable rules per OpenAI's Codex guidance. + * + * doctrine-plus-skills — doctrine + the relevant skill SKILL.md bodies + * inlined under "## Skill references". Used by + * the AGENTS.md handoff for editor agents + * (Cursor / Windsurf / Cline) that don't auto- + * load skill directories. + */ +export function buildAgentsMdBody( + integration: Integration, + mode: AgentsMdMode, +): string { + const doctrine = readDoctrine() + if (!doctrine) { + p.log.warn( + 'AGENTS.md doctrine fragment not found in this CLI build — writing a minimal AGENTS.md.', + ) + return [ + SENTINEL_START, + '', + '# CipherStash', + '', + 'See `.cipherstash/setup-prompt.md` for the action plan and the installed skills for the rules.', + '', + SENTINEL_END, + ].join('\n') + } + + const parts: string[] = [SENTINEL_START, '', doctrine.trim(), ''] + + if (mode === 'doctrine-plus-skills') { + const skillNames = SKILL_MAP[integration] + const skillBodies: string[] = [] + for (const name of skillNames) { + const body = readBundledSkill(name) + if (body) { + skillBodies.push(`---\n\n# Skill: ${name}\n\n${stripFrontmatter(body)}`) + } + } + if (skillBodies.length > 0) { + parts.push('## Skill references', '') + parts.push( + 'These are the CipherStash skills that apply to this project. They contain the API details and patterns the rules above reference.', + '', + ) + parts.push(skillBodies.join('\n\n')) + parts.push('') + } + } + + parts.push(SENTINEL_END) + return parts.join('\n') +} + +/** + * Strip a leading YAML frontmatter block (`---\n...---\n`) from a SKILL.md + * body. Skill files use frontmatter for the Claude/Codex skill registry; + * once we inline the content into AGENTS.md it just adds noise, since the + * `# Skill: ` heading we emit already labels each section. + */ +function stripFrontmatter(body: string): string { + if (!body.startsWith('---')) return body.trim() + const end = body.indexOf('\n---', 3) + if (end === -1) return body.trim() + return body.slice(end + 4).trim() +} + +/** + * Locate and read the bundled doctrine markdown. `tsup` copies + * `src/commands/init/doctrine/` into `dist/commands/init/doctrine/` at + * build time. Multi-layout fallback mirrors `install-skills.ts` so dev + * (running from `src/`) and prod builds both find the file. + */ +function readDoctrine(): string | undefined { + const here = currentDir() + const candidates = [ + join(here, 'doctrine', 'AGENTS-doctrine.md'), + join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), + // Dev: running from `packages/cli/src/commands/init/lib/` — sibling dir. + join(here, '..', 'doctrine', 'AGENTS-doctrine.md'), + // Prod: dist may flatten or preserve layout. + join(here, '..', '..', 'doctrine', 'AGENTS-doctrine.md'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return readFileSync(resolve(candidate), 'utf-8') + } + 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/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts new file mode 100644 index 00000000..967dfcde --- /dev/null +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -0,0 +1,123 @@ +import { cpSync, existsSync, mkdirSync, readFileSync } 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' + +/** + * Per-integration set of skills to install. The skills themselves live at + * the monorepo root in `/skills//SKILL.md` and ship inside the CLI + * tarball — see `tsup.config.ts`, which copies the directory into + * `dist/skills/` at build time. + * + * Mirror of the wizard's `SKILL_MAP` (packages/wizard/src/lib/install-skills.ts) + * extended with `postgresql` and `dynamodb` so init can hand off to those + * stacks too. Wizard keeps its own copy because it has its own `Integration` + * union (no `dynamodb`); merging is a separate cleanup. + */ +export const SKILL_MAP: Record = { + drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'], + supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'], + postgresql: ['stash-encryption', 'stash-cli'], + dynamodb: ['stash-encryption', 'stash-dynamodb', 'stash-cli'], +} + +/** + * Copy the per-integration set of skills into `///`. + * + * Unlike the wizard's variant, this does NOT prompt — by the time it runs, + * the user has already picked a handoff and the skills are part of that + * choice. Returns the names of skills actually copied. + * + * `destDir` is relative to `cwd` and dictates the per-tool location: + * `.claude/skills` for Claude Code, `.codex/skills` for Codex. + * + * Idempotent: re-runs overwrite the skill folders so the user always gets + * the latest content shipped with this CLI. + */ +export function installSkills( + cwd: string, + destDir: string, + integration: Integration, +): string[] { + const skills = SKILL_MAP[integration] + 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 destRoot = resolve(cwd, destDir) + 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}`) + } + } + + return copied +} + +/** + * Read the body of a single bundled skill's SKILL.md. Used by the AGENTS.md + * builder when the handoff target is an editor agent (Cursor / Windsurf / + * Cline) that doesn't auto-load skill directories — we inline the content. + * + * Returns undefined if the bundle isn't found or the named skill isn't part + * of the bundle. Callers should treat that as "skip this skill" rather than + * a fatal error so a stripped CLI build still produces a usable AGENTS.md. + */ +export function readBundledSkill(name: string): string | undefined { + const bundledRoot = resolveBundledSkillsRoot() + if (!bundledRoot) return undefined + const skillFile = join(bundledRoot, name, 'SKILL.md') + if (!existsSync(skillFile)) return undefined + return readFileSync(skillFile, 'utf-8') +} + +/** + * 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. In dev (running from `src/`) we also + * fall back to the monorepo root. + */ +function resolveBundledSkillsRoot(): string | undefined { + const here = currentDir() + const candidates = [ + join(here, 'skills'), + join(here, '..', 'skills'), + join(here, '..', '..', 'skills'), + join(here, '..', '..', '..', 'skills'), + // Dev fallback: when running from `packages/cli/src/commands/init/lib/`, + // the monorepo's `skills/` is six levels up. + 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/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts new file mode 100644 index 00000000..8ab8eb0f --- /dev/null +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -0,0 +1,232 @@ +import * as p from '@clack/prompts' +import pg from 'pg' +import type { ColumnDef, DataType, SchemaDef, SearchOp } from '../types.js' + +export interface DbColumn { + columnName: string + dataType: string + udtName: string + isEqlEncrypted: boolean +} + +export interface DbTable { + tableName: string + columns: DbColumn[] +} + +/** + * Map a Postgres `udt_name` (e.g. `int4`, `timestamptz`) onto the CipherStash + * `DataType` taxonomy. Anything we can't classify falls back to `string`, + * which is the safest "treat the value as opaque text" default. + */ +export function pgTypeToDataType(udtName: string): DataType { + switch (udtName) { + case 'int2': + case 'int4': + case 'int8': + case 'float4': + case 'float8': + case 'numeric': + return 'number' + case 'bool': + return 'boolean' + case 'date': + case 'timestamp': + case 'timestamptz': + return 'date' + case 'json': + case 'jsonb': + return 'json' + default: + return 'string' + } +} + +/** + * Read every base table in the `public` schema along with its columns. + * + * The `eql_v2_encrypted` UDT marker tells us a column is already managed by + * CipherStash — useful for re-runs against a partially set up DB so we can + * pre-select those columns rather than asking the user to reconfirm. + */ +export async function introspectDatabase( + databaseUrl: string, +): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + try { + await client.connect() + + const { rows } = await client.query<{ + table_name: string + column_name: string + data_type: string + udt_name: string + }>(` + SELECT c.table_name, c.column_name, c.data_type, c.udt_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name, c.ordinal_position + `) + + const tableMap = new Map() + for (const row of rows) { + const cols = tableMap.get(row.table_name) ?? [] + cols.push({ + columnName: row.column_name, + dataType: row.data_type, + udtName: row.udt_name, + isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', + }) + tableMap.set(row.table_name, cols) + } + + return Array.from(tableMap.entries()).map(([tableName, columns]) => ({ + tableName, + columns, + })) + } finally { + await client.end() + } +} + +function allSearchOps(dataType: DataType): SearchOp[] { + const ops: SearchOp[] = ['equality', 'orderAndRange'] + if (dataType === 'string') { + ops.push('freeTextSearch') + } + return ops +} + +/** + * Interactive multi-select: which columns in which table should be encrypted? + * + * Returns `undefined` if the user cancels at any prompt — callers should + * propagate the cancellation rather than treating it as "no columns selected". + * + * Pre-selects columns that are already `eql_v2_encrypted` so re-running on a + * partially encrypted DB is a no-op by default. + */ +export async function selectTableColumns( + tables: DbTable[], +): Promise { + const selectedTable = await p.select({ + message: 'Which table do you want to encrypt columns in?', + options: tables.map((t) => { + const eqlCount = t.columns.filter((c) => c.isEqlEncrypted).length + const hint = + eqlCount > 0 + ? `${t.columns.length} columns, ${eqlCount} already encrypted` + : `${t.columns.length} column${t.columns.length !== 1 ? 's' : ''}` + return { value: t.tableName, label: t.tableName, hint } + }), + }) + + if (p.isCancel(selectedTable)) return undefined + + const table = tables.find((t) => t.tableName === selectedTable) + if (!table) return undefined + + const eqlColumns = table.columns.filter((c) => c.isEqlEncrypted) + + if (eqlColumns.length > 0) { + p.log.info( + `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`, + ) + } + + const selectedColumns = await p.multiselect({ + message: `Which columns in "${selectedTable}" should be in the encryption schema?`, + options: table.columns.map((col) => ({ + value: col.columnName, + label: col.columnName, + hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType, + })), + required: true, + initialValues: eqlColumns.map((c) => c.columnName), + }) + + if (p.isCancel(selectedColumns)) return undefined + + const searchable = await p.confirm({ + message: + 'Enable searchable encryption on these columns? (you can fine-tune indexes later)', + initialValue: true, + }) + + if (p.isCancel(searchable)) return undefined + + const columns: ColumnDef[] = selectedColumns.map((colName) => { + const dbCol = table.columns.find((c) => c.columnName === colName) + if (!dbCol) { + // Unreachable — multiselect only emits values from the source array. + throw new Error(`Column ${colName} not found in table ${selectedTable}`) + } + const dataType = pgTypeToDataType(dbCol.udtName) + const searchOps = searchable ? allSearchOps(dataType) : [] + return { name: colName, dataType, searchOps } + }) + + p.log.success( + `Schema defined: ${selectedTable} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, + ) + + return { tableName: selectedTable, columns } +} + +/** + * Connect, introspect, and let the user pick columns in one or more tables. + * + * Returns `undefined` for any of: + * - connection failure + * - empty database (no public tables) + * - user cancellation at any prompt + * + * Callers distinguish "user wanted no schemas" from "DB has nothing to pick" + * by also checking `introspectDatabase` separately when needed. + */ +export async function buildSchemasFromDatabase( + databaseUrl: string, +): Promise { + const s = p.spinner() + s.start('Connecting to database and reading schema...') + + let tables: DbTable[] + try { + tables = await introspectDatabase(databaseUrl) + } catch (error) { + s.stop('Failed to connect to database.') + p.log.error(error instanceof Error ? error.message : 'Unknown error') + return undefined + } + + if (tables.length === 0) { + s.stop('No tables found in the public schema.') + return undefined + } + + s.stop( + `Found ${tables.length} table${tables.length !== 1 ? 's' : ''} in the public schema.`, + ) + + const schemas: SchemaDef[] = [] + + while (true) { + const schema = await selectTableColumns(tables) + if (!schema) return undefined + + schemas.push(schema) + + const addMore = await p.confirm({ + message: 'Encrypt columns in another table?', + initialValue: false, + }) + + if (p.isCancel(addMore)) return undefined + if (!addMore) break + } + + return schemas +} diff --git a/packages/cli/src/commands/init/lib/sentinel-upsert.ts b/packages/cli/src/commands/init/lib/sentinel-upsert.ts new file mode 100644 index 00000000..1fd47834 --- /dev/null +++ b/packages/cli/src/commands/init/lib/sentinel-upsert.ts @@ -0,0 +1,65 @@ +/** + * Managed-block upsert for files that we co-own with the user. + * + * The sentinel pair lets us re-run `stash init` and replace only our managed + * region, leaving anything the user wrote outside the sentinels alone. + * + * + * ...managed content... + * + */ + +const START = '' +const END = '' + +export interface UpsertOptions { + /** Existing file contents, or undefined when the file does not yet exist. */ + existing?: string + /** New content to put between the sentinels. Trailing newline normalised. */ + managed: string +} + +/** + * Insert or replace the managed block. + * + * - File missing → return managed content wrapped in sentinels. + * - Sentinel pair found → replace what is between them. + * - Sentinels missing but file exists → append the managed block, separated by + * a blank line so we never collide with the user's last paragraph. + * - Mismatched sentinels (only start, only end, or end before start) → throw. + * Surfacing this loudly is better than silently mangling the file. + */ +export function upsertManagedBlock({ + existing, + managed, +}: UpsertOptions): string { + const block = `${START}\n${managed.replace(/\s+$/, '')}\n${END}\n` + + if (existing === undefined || existing.length === 0) { + return block + } + + const startIdx = existing.indexOf(START) + const endIdx = existing.indexOf(END) + + if (startIdx === -1 && endIdx === -1) { + const sep = existing.endsWith('\n') ? '\n' : '\n\n' + return `${existing}${sep}${block}` + } + + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { + throw new Error( + 'cipherstash:rulebook sentinel pair is malformed. Refusing to overwrite. ' + + 'Remove the leftover sentinel manually and re-run.', + ) + } + + const before = existing.slice(0, startIdx) + const after = existing.slice(endIdx + END.length) + // Drop a single leading newline on `after` to avoid double-blank lines. + const tail = after.startsWith('\n') ? after.slice(1) : after + return `${before}${block}${tail}` +} + +export const SENTINEL_START = START +export const SENTINEL_END = END diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts new file mode 100644 index 00000000..e43a4765 --- /dev/null +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -0,0 +1,242 @@ +import type { HandoffChoice, Integration } from '../types.js' +import { type PackageManager, runnerCommand } from '../utils.js' + +export interface SetupPromptContext { + integration: Integration + encryptionClientPath: string + packageManager: PackageManager + schemaFromIntrospection: boolean + eqlInstalled: boolean + stackInstalled: boolean + cliInstalled: boolean + /** Which handoff option the user picked. Lets us tailor wording (e.g. the + * Codex prompt names AGENTS.md, Claude names the skill). */ + handoff: HandoffChoice + /** Names of skills `stash init` copied into the project (e.g. + * `stash-encryption`, `stash-drizzle`, `stash-cli`). The action prompt + * names them so the agent knows which references to consult. Empty for + * the `agents-md` handoff (no skills directory installed) and for + * `wizard` (the wizard installs its own). */ + installedSkills: string[] +} + +interface MigrationCommands { + generate: string + apply: string + /** Human-readable label for the migration tool ("Drizzle Kit", "Prisma"). */ + tool: string +} + +/** + * Per-integration migration commands. We compute these from the detected + * package manager + integration so the agent gets the exact string it should + * run, not a generic "run your migrations" hand-wave. + */ +function migrationCommands( + integration: Integration, + pm: PackageManager, +): MigrationCommands | undefined { + if (integration === 'drizzle') { + return { + tool: 'Drizzle Kit', + generate: `${execCommand(pm)} drizzle-kit generate`, + apply: `${execCommand(pm)} drizzle-kit migrate`, + } + } + if (integration === 'supabase') { + return { + tool: 'Supabase CLI', + generate: 'supabase migration new ', + apply: 'supabase migration up (remote) or supabase db reset (local)', + } + } + return undefined +} + +/** + * Map the package manager to the right "run a binary from node_modules" form. + * npm → `npx --no-install` (avoid surprise downloads when the dep should + * already be installed) + * pnpm → `pnpm exec` + * yarn → `yarn` (yarn 1) or `yarn run` — `yarn ` works for both + * bun → `bun x` (binary-runner mode, not the dlx alias) + */ +function execCommand(pm: PackageManager): string { + switch (pm) { + case 'npm': + return 'npx --no-install' + case 'pnpm': + return 'pnpm exec' + case 'yarn': + return 'yarn' + case 'bun': + return 'bun x' + } +} + +function bullet(line: string): string { + return `- ${line}` +} + +function checked(line: string): string { + return `- [x] ${line}` +} + +function todo(line: string): string { + return `- [ ] ${line}` +} + +/** + * Phrase the "where the rules live" pointer for each handoff target. + * + * claude-code → skills loaded into `.claude/skills/` + * codex → AGENTS.md (durable doctrine) + skills in `.codex/skills/` + * agents-md → AGENTS.md only (Cursor / Windsurf / Cline don't load + * skill directories, so the rules are inlined there) + * wizard → handled separately; this prompt isn't written for wizard + */ +function rulesPointer( + handoff: HandoffChoice, + installedSkills: string[], +): string { + const skillNames = installedSkills.length + ? installedSkills.map((s) => `\`${s}\``).join(', ') + : '' + if (handoff === 'claude-code') { + return `the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.claude/skills/\`` + } + if (handoff === 'codex') { + return `\`AGENTS.md\` (durable rules) + the ${skillNames} skill${installedSkills.length !== 1 ? 's' : ''} loaded into \`.codex/skills/\`` + } + return 'the `AGENTS.md` at the project root' +} + +/** + * Render the project-specific action prompt. + * + * This is the file the agent reads first — it tells them exactly what state + * the project is in, what's already done, and what to do next, with concrete + * paths and commands. The skills / AGENTS.md provide reusable rules; this + * file is the imperative for *this run*. + * + * Structure: header → "what's done" checklist → "what's next" actionable list + * → reference to the skills/AGENTS.md for the rules. + */ +export function renderSetupPrompt(ctx: SetupPromptContext): string { + const cli = runnerCommand(ctx.packageManager, 'stash') + const migration = migrationCommands(ctx.integration, ctx.packageManager) + + const done: string[] = [ + checked('Authenticated to CipherStash and selected a workspace'), + checked(`Detected integration: \`${ctx.integration}\``), + checked( + `Wrote the encryption client to \`${ctx.encryptionClientPath}\` (${ + ctx.schemaFromIntrospection + ? 'sourced from live database introspection' + : "PLACEHOLDER schema — not yet aligned to the user's real data model" + })`, + ), + ] + if (ctx.stackInstalled) { + done.push(checked('Installed `@cipherstash/stack` (runtime)')) + } + if (ctx.cliInstalled) { + done.push(checked('Installed `stash` (CLI, dev dep)')) + } + if (ctx.eqlInstalled) { + done.push( + checked( + 'Installed the EQL extension into the database (`stash db install`)', + ), + ) + } + + const next: string[] = [] + + if (!ctx.eqlInstalled) { + next.push( + todo( + `**Install EQL into the database** — run \`${cli} db install\`. This is required before any migration that creates encrypted columns.`, + ), + ) + } + + if (!ctx.schemaFromIntrospection) { + next.push( + todo( + `**Reshape the encryption client.** \`${ctx.encryptionClientPath}\` currently uses a placeholder \`users\` table with \`email\` and \`name\` columns. Read the user's existing schema (probably under \`src/db/\` or similar for ${ctx.integration}), decide which real tables and columns should be encrypted, and update the encryption client to match. Refer to the skills for the column types and constraints to use.`, + ), + ) + } + + if (ctx.integration === 'drizzle') { + next.push( + todo( + `**Wire the encryption client into Drizzle config.** Make sure \`drizzle.config.ts\`'s \`schema\` field includes the encryption client file so \`drizzle-kit generate\` picks up the encrypted columns. If the user keeps a single \`schema.ts\`, re-export the table definitions from there instead.`, + ), + ) + } + + if (ctx.integration === 'supabase') { + next.push( + todo( + '**Wrap the Supabase client.** Find every call to `createClient` / `createServerClient` / `createBrowserClient` from `@supabase/supabase-js` or `@supabase/ssr`. Wrap each with `encryptedSupabase({ encryptionClient, supabaseClient })` from `@cipherstash/stack/supabase` (see the `stash-supabase` skill for the exact API).', + ), + ) + } + + if (migration) { + next.push( + todo( + `**Generate the migration** — \`${migration.generate}\` (${migration.tool}). Verify the generated SQL declares encrypted columns as nullable \`jsonb\`. Never \`NOT NULL\` on creation.`, + ), + ) + next.push( + todo( + `**Apply the migration** — \`${migration.apply}\`. Show the user the generated SQL before running.`, + ), + ) + } else { + next.push( + todo( + '**Generate and apply a migration** that adds the encrypted columns as nullable `jsonb`. The exact tooling depends on the project — pick the one already in use.', + ), + ) + } + + next.push( + todo( + '**Verify with a round-trip.** Insert a record through the encryption client, select it back, confirm the value decrypts and the search ops work as expected.', + ), + ) + + return [ + '# CipherStash setup — action plan', + '', + `Integration: ${ctx.integration}`, + `Package manager: ${ctx.packageManager}`, + '', + `You are picking up a CipherStash setup that \`stash init\` has started. Read this file in full before touching anything. Project-specific facts live in \`.cipherstash/context.json\`. Reusable rules (column types, things never to touch, never-\`.notNull()\`-on-encrypted etc.) live in ${rulesPointer(ctx.handoff, ctx.installedSkills)}.`, + '', + '## What `stash init` already did', + '', + ...done, + '', + '## What you need to do', + '', + ...next, + '', + '## Stop and ask the user when', + '', + bullet( + 'Schema reshaping involves dropping or renaming a column with existing data — this needs a backfill plan, not a rename.', + ), + bullet( + 'You discover existing encrypted columns that disagree with the encryption client — someone else may have run `stash init` earlier with different choices.', + ), + bullet( + 'A migration would change the data type of a column the user has already filled.', + ), + '', + ].join('\n') +} diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts new file mode 100644 index 00000000..20ff4836 --- /dev/null +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { + HandoffChoice, + InitState, + Integration, + SchemaDef, +} from '../types.js' +import { + type PackageManager, + detectPackageManager, + prodInstallCommand, +} from '../utils.js' +import { type SetupPromptContext, renderSetupPrompt } from './setup-prompt.js' + +export const CONTEXT_REL_PATH = '.cipherstash/context.json' +export const SETUP_PROMPT_REL_PATH = '.cipherstash/setup-prompt.md' + +export interface ContextFile { + cliVersion: string + integration: Integration + encryptionClientPath: string + packageManager: PackageManager + installCommand: string + envKeys: string[] + /** Every encrypted-table schema written to the encryption client. The + * generated client file is still authoritative for column types and ops; + * this lets agents see the full set without parsing TypeScript. */ + schemas: SchemaDef[] + /** Names of skills `stash init` copied into the project (e.g. + * `stash-encryption`, `stash-drizzle`, `stash-cli`). Empty for the + * AGENTS.md handoff (skill content is inlined into AGENTS.md instead) + * and for wizard (the wizard installs its own). */ + installedSkills: string[] + generatedAt: string +} + +/** + * Walk up from this file to find the CLI's package.json. The compiled file + * lives at `dist/index.js` (or similar) and the source at + * `src/commands/init/lib/write-context.ts`, so we walk up to six levels. + * Falling back to `'unknown'` is fine — the field is informational. + */ +export function readCliVersion(): string { + let dir = dirname(fileURLToPath(import.meta.url)) + for (let i = 0; i < 6; i++) { + const candidate = resolve(dir, 'package.json') + if (existsSync(candidate)) { + try { + const pkg = JSON.parse(readFileSync(candidate, 'utf-8')) as { + name?: string + version?: string + } + if (pkg.name === 'stash' && pkg.version) return pkg.version + } catch { + // keep walking + } + } + dir = dirname(dir) + } + return 'unknown' +} + +function ensureDir(path: string): void { + const dir = dirname(path) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +/** + * Build the universal `.cipherstash/context.json` from `InitState`. Throws + * on a missing schema — the build-schema step is required to have run + * before any handoff fires. + */ +export function buildContextFile(state: InitState): ContextFile { + const integration = state.integration ?? 'postgresql' + const clientFilePath = state.clientFilePath ?? './src/encryption/index.ts' + const schemas = state.schemas + if (!schemas || schemas.length === 0) { + // Should not happen — build-schema always populates this. Keep the + // assertion explicit so a future refactor that drops the field gets + // caught here rather than producing a half-empty context.json. + throw new Error('Schemas missing from init state — cannot write context.') + } + + const pm = detectPackageManager() + return { + cliVersion: readCliVersion(), + integration, + encryptionClientPath: clientFilePath, + packageManager: pm, + installCommand: prodInstallCommand(pm, '@cipherstash/stack'), + envKeys: [], + schemas, + installedSkills: [], + generatedAt: new Date().toISOString(), + } +} + +/** + * Persist the context file to disk. The CLI owns this path; never call from + * outside the init / handoff steps. + */ +export function writeContextFile(absPath: string, ctx: ContextFile): void { + ensureDir(absPath) + writeFileSync(absPath, `${JSON.stringify(ctx, null, 2)}\n`, 'utf-8') +} + +/** + * Write `.cipherstash/context.json` immediately after the encryption client + * is generated. Handoff steps refresh it later with the list of skills they + * installed; this baseline guarantees the file is always in sync with the + * encryption client even if init aborts mid-flow. + * + * Without this baseline, a failed install-eql or a Ctrl+C between + * build-schema and the handoff would leave context.json from a previous + * run on disk — which an agent reading it would happily believe. + */ +export function writeBaselineContextFile( + state: InitState, + cwd: string, + envKeys: string[], +): void { + if (!state.schemas || state.schemas.length === 0) return + const absPath = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state) + ctx.envKeys = envKeys + writeContextFile(absPath, ctx) +} + +/** + * Build a `SetupPromptContext` from the current init state for the given + * handoff choice. Returns `undefined` for the wizard handoff — the wizard + * has its own prompt logic and doesn't read this file. + */ +export function buildSetupPromptContext( + state: InitState, + handoff: HandoffChoice, + installedSkills: string[], +): SetupPromptContext | undefined { + if (handoff === 'wizard') return undefined + const integration = state.integration ?? 'postgresql' + const encryptionClientPath = + state.clientFilePath ?? './src/encryption/index.ts' + return { + integration, + encryptionClientPath, + packageManager: detectPackageManager(), + schemaFromIntrospection: state.schemaFromIntrospection ?? false, + eqlInstalled: state.eqlInstalled ?? false, + stackInstalled: state.stackInstalled ?? false, + cliInstalled: state.cliInstalled ?? false, + handoff, + installedSkills, + } +} + +/** + * Render and persist `.cipherstash/setup-prompt.md`. The file is plain + * markdown — no sentinel markers — because it's regenerated wholesale on + * every init run and is meant to reflect the current state, not a managed + * block alongside user content. + */ +export function writeSetupPrompt( + absPath: string, + ctx: SetupPromptContext, +): void { + ensureDir(absPath) + writeFileSync(absPath, renderSetupPrompt(ctx), 'utf-8') +} diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index 0b966dcd..e2b9ab01 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -2,19 +2,27 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' import { detectDrizzle, detectSupabase } from '../../db/detect.js' +import { buildSchemasFromDatabase } from '../lib/introspect.js' +import { writeBaselineContextFile } from '../lib/write-context.js' import type { - Integration, InitProvider, InitState, InitStep, + Integration, + SchemaDef, } from '../types.js' import { CancelledError } from '../types.js' -import { generatePlaceholderClient } from '../utils.js' +import { + PLACEHOLDER_SCHEMA, + generateClientFromSchemas, + generatePlaceholderClient, +} from '../utils.js' +import { readEnvKeyNames } from './gather-context.js' const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' /** - * Pick the placeholder template by reading the same signals `db install` + * Pick the integration 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. @@ -33,13 +41,11 @@ export const buildSchemaStep: InitStep = { name: 'Generate encryption client', async run(state: InitState, _provider: InitProvider): Promise { const cwd = process.cwd() - const integration = detectIntegration(cwd, process.env.DATABASE_URL) + const integration = detectIntegration(cwd, state.databaseUrl) const clientFilePath = DEFAULT_CLIENT_PATH const resolvedPath = resolve(cwd, clientFilePath) - // 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. + // Existing-file branch: silent overwrite is bad. Ask once. if (existsSync(resolvedPath)) { const action = await p.select({ message: `${clientFilePath} already exists. What would you like to do?`, @@ -57,11 +63,40 @@ export const buildSchemaStep: InitStep = { if (action === 'keep') { p.log.info('Keeping existing encryption client file.') - return { ...state, clientFilePath, schemaGenerated: false } + return { + ...state, + clientFilePath, + schemaGenerated: false, + integration, + schemas: [PLACEHOLDER_SCHEMA], + schemaFromIntrospection: false, + } } } - const fileContents = generatePlaceholderClient(integration) + // Try real introspection first. Falls through to placeholder for an + // empty database, a connection error, or user cancellation at any prompt. + let introspected: SchemaDef[] | undefined + if (state.databaseUrl) { + introspected = await buildSchemasFromDatabase(state.databaseUrl) + } + + let fileContents: string + let recordedSchemas: SchemaDef[] + let fromIntrospection: boolean + + if (introspected && introspected.length > 0) { + fileContents = generateClientFromSchemas(integration, introspected) + recordedSchemas = introspected + fromIntrospection = true + } else { + p.log.info( + 'No tables found in the public schema — writing a placeholder client. The handoff prompt will note this so the agent reshapes it to your real schema.', + ) + fileContents = generatePlaceholderClient(integration) + recordedSchemas = [PLACEHOLDER_SCHEMA] + fromIntrospection = false + } const dir = dirname(resolvedPath) if (!existsSync(dir)) { @@ -70,9 +105,34 @@ export const buildSchemaStep: InitStep = { writeFileSync(resolvedPath, fileContents, 'utf-8') p.log.success( - `Encryption client written to ${clientFilePath} (${integration} template)`, + fromIntrospection + ? `Encryption client written to ${clientFilePath} (${integration}, ${recordedSchemas.length} table${recordedSchemas.length !== 1 ? 's' : ''} from introspection)` + : `Encryption client written to ${clientFilePath} (${integration} placeholder)`, ) - return { ...state, clientFilePath, schemaGenerated: true } + // Read env-key names once and put them on state. gather-context (later in + // the pipeline) and the handoff steps all read from there rather than + // re-scanning `.env*` files. Names only — never values. + const envKeys = readEnvKeyNames(cwd) + + const nextState: InitState = { + ...state, + clientFilePath, + schemaGenerated: true, + integration, + schemas: recordedSchemas, + schemaFromIntrospection: fromIntrospection, + envKeys, + } + + // Write a baseline `.cipherstash/context.json` immediately so it tracks + // the encryption client we just generated. Handoff steps refresh it + // later with the list of installed skills, but this guarantees the file + // is consistent with the client even if init aborts before the handoff + // (e.g. install-eql failure, Ctrl+C). Without this, an agent reading a + // stale context.json from a previous run would happily believe it. + writeBaselineContextFile(nextState, cwd, envKeys) + + return nextState }, } diff --git a/packages/cli/src/commands/init/steps/gather-context.ts b/packages/cli/src/commands/init/steps/gather-context.ts new file mode 100644 index 00000000..0629a7d2 --- /dev/null +++ b/packages/cli/src/commands/init/steps/gather-context.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { detectAgents } from '../detect-agents.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { detectPackageManager } from '../utils.js' + +/** + * Names of env keys observed in the project's `.env*` files. We never read or + * propagate the values — only the names tell the agent which keys to expect. + * + * Exported so build-schema can populate `state.envKeys` once at the start of + * the run; the handoff steps then read from state. Keeping the function here + * (rather than under `lib/`) groups it with the other context-gathering + * helpers. + */ +export function readEnvKeyNames(cwd: string): string[] { + const candidates = [ + '.env', + '.env.local', + '.env.development', + '.env.development.local', + ] + const seen = new Set() + for (const file of candidates) { + const path = resolve(cwd, file) + if (!existsSync(path)) continue + let text: string + try { + text = readFileSync(path, 'utf-8') + } catch { + continue + } + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const key = trimmed.slice(0, eq).trim() + if (key) seen.add(key) + } + } + return Array.from(seen).sort() +} + +/** + * Detect available coding agents and log a one-line summary of the state + * the user just set up. + * + * Env keys are already on `state.envKeys` (populated by build-schema); we + * only read them off state here to mention the count. No file writes. + */ +export const gatherContextStep: InitStep = { + id: 'gather-context', + name: 'Gather setup context', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const agents = detectAgents(cwd, process.env) + const pm = detectPackageManager() + const envKeyCount = state.envKeys?.length ?? 0 + + const detectedBits: string[] = [] + if (state.integration) + detectedBits.push(`integration: ${state.integration}`) + detectedBits.push(`package manager: ${pm}`) + if (agents.cli.claudeCode) detectedBits.push('Claude Code CLI: yes') + if (agents.cli.codex) detectedBits.push('Codex CLI: yes') + if (envKeyCount > 0) { + detectedBits.push(`env keys: ${envKeyCount} found`) + } + + p.log.info(`Detected — ${detectedBits.join(', ')}`) + + return { ...state, agents } + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/init/steps/handoff-agents-md.ts new file mode 100644 index 00000000..9a4660f1 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-agents-md.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { + CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, + buildContextFile, + buildSetupPromptContext, + writeContextFile, + writeSetupPrompt, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +const AGENTS_MD_REL_PATH = 'AGENTS.md' + +/** + * Write `AGENTS.md`, `.cipherstash/context.json`, and + * `.cipherstash/setup-prompt.md`, then stop. + * + * For users running editor-based agents (Cursor, Windsurf, Cline) or any + * tool that follows the AGENTS.md convention but does NOT auto-load skill + * directories. We inline the relevant skill content into AGENTS.md so the + * agent has the API details right there. + * + * No `.codex/skills/` or `.claude/skills/` directory is written — those + * tools wouldn't know to look there. Re-runs replace only the sentinel + * region in AGENTS.md. + */ +export const handoffAgentsMdStep: InitStep = { + id: 'handoff-agents-md', + name: 'Write AGENTS.md', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const envKeys = state.envKeys ?? [] + + const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) + const managed = buildAgentsMdBody(integration, 'doctrine-plus-skills') + const existing = existsSync(agentsMdAbs) + ? readFileSync(agentsMdAbs, 'utf-8') + : undefined + writeFileSync( + agentsMdAbs, + upsertManagedBlock({ existing, managed }), + 'utf-8', + ) + p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state) + ctx.envKeys = envKeys + // No skill directory installed for editor-agent users; the rules are + // inlined directly into AGENTS.md. + ctx.installedSkills = [] + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + const promptCtx = buildSetupPromptContext(state, 'agents-md', []) + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + + p.note( + [ + `Rules at ${AGENTS_MD_REL_PATH}`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Cursor / Windsurf / Cline pick up AGENTS.md automatically.', + `Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`, + ].join('\n'), + 'Drive your editor agent', + ) + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts new file mode 100644 index 00000000..ddaf21f6 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -0,0 +1,108 @@ +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { installSkills } from '../lib/install-skills.js' +import { + CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, + buildContextFile, + buildSetupPromptContext, + writeContextFile, + writeSetupPrompt, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +const CLAUDE_SKILLS_DIR = '.claude/skills' + +const CLAUDE_INSTALL_URL = + 'https://docs.claude.com/en/docs/claude-code/quickstart' + +/** + * Spawn `claude` interactively in the user's terminal so they can watch tool + * calls and approve edits. We attach stdio to inherit; this step blocks until + * the user exits Claude Code. + * + * Returns the exit code — 0 means the user finished the session normally, + * non-zero means `claude` crashed or was interrupted. We don't fail init + * either way: the artifacts are already written, the user can re-run claude. + */ +function spawnClaude(prompt: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn('claude', [prompt], { + stdio: 'inherit', + shell: false, + }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', () => resolvePromise(-1)) + }) +} + +/** + * Hand off to Claude Code: copy the per-integration set of skills into + * `.claude/skills/`, write `.cipherstash/context.json` and + * `.cipherstash/setup-prompt.md`, then spawn `claude`. If `claude` is not + * on PATH we still write the artifacts and print install + manual-launch + * instructions. + * + * The launch prompt points the agent at `setup-prompt.md` first — that's + * the project-specific action plan. Claude auto-loads the installed skills + * for the durable rules and API references. + */ +export const handoffClaudeStep: InitStep = { + id: 'handoff-claude', + name: 'Hand off to Claude Code', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const envKeys = state.envKeys ?? [] + + const installed = installSkills(cwd, CLAUDE_SKILLS_DIR, integration) + if (installed.length > 0) { + p.log.success( + `Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''} into ${CLAUDE_SKILLS_DIR}/: ${installed.join(', ')}`, + ) + } + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state) + ctx.envKeys = envKeys + ctx.installedSkills = installed + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + const promptCtx = buildSetupPromptContext(state, 'claude-code', installed) + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` + + if (!state.agents?.cli.claudeCode) { + p.note( + [ + 'Claude Code is not installed on this machine.', + `Install: ${CLAUDE_INSTALL_URL}`, + '', + 'Once installed, run:', + // Single-quote the prompt for the printed example. The launchPrompt + // is a closed-form string we control, but printing it inside double + // quotes would break if any path inside ever contained a quote. + ` claude '${launchPrompt}'`, + ].join('\n'), + 'Files written — install Claude Code to run the handoff', + ) + return state + } + + p.log.info('Launching Claude Code...') + const exitCode = await spawnClaude(launchPrompt) + if (exitCode !== 0) { + p.log.warn( + `Claude Code exited with code ${exitCode}. Re-run \`claude '${launchPrompt}'\` to resume.`, + ) + } + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts new file mode 100644 index 00000000..a1b58e57 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -0,0 +1,109 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { buildAgentsMdBody } from '../lib/build-agents-md.js' +import { installSkills } from '../lib/install-skills.js' +import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { + CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, + buildContextFile, + buildSetupPromptContext, + writeContextFile, + writeSetupPrompt, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +const AGENTS_MD_REL_PATH = 'AGENTS.md' +const CODEX_SKILLS_DIR = '.codex/skills' + +const CODEX_INSTALL_URL = 'https://github.com/openai/codex' + +function spawnCodex(prompt: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn('codex', [prompt], { + stdio: 'inherit', + shell: false, + }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', () => resolvePromise(-1)) + }) +} + +/** + * Hand off to Codex CLI. Following OpenAI's Codex guidance, AGENTS.md + * holds durable doctrine ("never log plaintext", "encrypted columns are + * jsonb null", three-phase migration etc.) while the procedural skills + * live in `.codex/skills/`. Both are written here. + * + * AGENTS.md is sentinel-upserted so re-runs replace only our region and + * any user content outside it survives. + */ +export const handoffCodexStep: InitStep = { + id: 'handoff-codex', + name: 'Hand off to Codex', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + const envKeys = state.envKeys ?? [] + + const installed = installSkills(cwd, CODEX_SKILLS_DIR, integration) + if (installed.length > 0) { + p.log.success( + `Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''} into ${CODEX_SKILLS_DIR}/: ${installed.join(', ')}`, + ) + } + + const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH) + const managed = buildAgentsMdBody(integration, 'doctrine-only') + const existing = existsSync(agentsMdAbs) + ? readFileSync(agentsMdAbs, 'utf-8') + : undefined + writeFileSync( + agentsMdAbs, + upsertManagedBlock({ existing, managed }), + 'utf-8', + ) + p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`) + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state) + ctx.envKeys = envKeys + ctx.installedSkills = installed + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + const promptCtx = buildSetupPromptContext(state, 'codex', installed) + if (promptCtx) { + writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx) + p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`) + } + + const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` + + if (!state.agents?.cli.codex) { + p.note( + [ + 'Codex is not installed on this machine.', + `Install: ${CODEX_INSTALL_URL}`, + '', + 'Once installed, run:', + ` codex '${launchPrompt}'`, + ].join('\n'), + 'Files written — install Codex to run the handoff', + ) + return state + } + + p.log.info('Launching Codex...') + const exitCode = await spawnCodex(launchPrompt) + if (exitCode !== 0) { + p.log.warn( + `Codex exited with code ${exitCode}. Re-run \`codex '${launchPrompt}'\` to resume.`, + ) + } + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/init/steps/handoff-wizard.ts new file mode 100644 index 00000000..160b24c5 --- /dev/null +++ b/packages/cli/src/commands/init/steps/handoff-wizard.ts @@ -0,0 +1,47 @@ +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { runWizardSpawn } from '../../wizard/index.js' +import { + CONTEXT_REL_PATH, + buildContextFile, + writeContextFile, +} from '../lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +/** + * Hand off to the CipherStash Agent (the in-house wizard package). + * + * Writes `.cipherstash/context.json` so the wizard has the same prepared + * facts the other handoffs use, then spawns the wizard via `runWizardSpawn` + * — the same path the top-level `stash wizard` subcommand takes, but with + * the exit code surfaced rather than `process.exit`-ed so init can finish + * its own outro and `next-steps` step. + * + * No skills are installed here. The wizard fetches its own agent-side + * prompt from the gateway and runs its own `maybeInstallSkills` flow. + */ +export const handoffWizardStep: InitStep = { + id: 'handoff-wizard', + name: 'Use the CipherStash Agent', + async run(state: InitState, _provider: InitProvider): Promise { + const cwd = process.cwd() + const envKeys = state.envKeys ?? [] + + const contextAbs = resolve(cwd, CONTEXT_REL_PATH) + const ctx = buildContextFile(state) + ctx.envKeys = envKeys + writeContextFile(contextAbs, ctx) + p.log.success(`Wrote ${CONTEXT_REL_PATH}`) + + // Pass through no extra flags. If a user wants to debug the wizard, they + // can re-run `stash wizard --debug` directly afterwards. + const exitCode = await runWizardSpawn([]) + if (exitCode !== 0) { + p.log.warn( + `Wizard exited with code ${exitCode}. Re-run \`stash wizard\` to resume.`, + ) + } + + return state + }, +} diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/init/steps/how-to-proceed.ts new file mode 100644 index 00000000..46d85499 --- /dev/null +++ b/packages/cli/src/commands/init/steps/how-to-proceed.ts @@ -0,0 +1,93 @@ +import * as p from '@clack/prompts' +import { + CancelledError, + type HandoffChoice, + type InitProvider, + type InitState, + type InitStep, +} from '../types.js' +import { handoffAgentsMdStep } from './handoff-agents-md.js' +import { handoffClaudeStep } from './handoff-claude.js' +import { handoffCodexStep } from './handoff-codex.js' +import { handoffWizardStep } from './handoff-wizard.js' + +/** + * Pick the default option in the four-way menu. + * + * Detected CLIs win — Claude Code first, then Codex. Otherwise we default to + * the AGENTS.md path because that's the broadest "works without anything else + * installed" option. The CipherStash Agent option is positioned as a fallback + * (slow first run, requires the wizard package on top of the CLI) and is + * never selected by default. + */ +function defaultChoice(state: InitState): HandoffChoice { + if (state.agents?.cli.claudeCode) return 'claude-code' + if (state.agents?.cli.codex) return 'codex' + return 'agents-md' +} + +/** + * Build the option list for the four-way menu. Hints reflect detection state + * — a missing CLI doesn't hide the option (handoff steps still write the + * rules files and print install instructions), it just nudges the user. + */ +function buildOptions( + state: InitState, +): { value: HandoffChoice; label: string; hint?: string }[] { + const claudeHint = state.agents?.cli.claudeCode + ? 'claude detected — will launch interactively' + : 'claude not on PATH — files will be written, install link shown' + const codexHint = state.agents?.cli.codex + ? 'codex detected — will launch interactively' + : 'codex not on PATH — files will be written, install link shown' + + return [ + { + value: 'claude-code', + label: 'Hand off to Claude Code', + hint: claudeHint, + }, + { + value: 'codex', + label: 'Hand off to Codex', + hint: codexHint, + }, + { + value: 'wizard', + label: 'Use the CipherStash Agent', + hint: 'our hosted setup wizard (runs `stash wizard`)', + }, + { + value: 'agents-md', + label: 'Write AGENTS.md', + hint: 'works with Cursor, Windsurf, Cline, and more', + }, + ] +} + +export const howToProceedStep: InitStep = { + id: 'how-to-proceed', + name: 'How to proceed', + async run(state: InitState, provider: InitProvider): Promise { + const choice = await p.select({ + message: 'How would you like to finish setup?', + options: buildOptions(state), + initialValue: defaultChoice(state), + }) + + if (p.isCancel(choice)) throw new CancelledError() + + const next: InitState = { ...state, handoff: choice } + + switch (choice) { + case 'claude-code': + return handoffClaudeStep.run(next, provider) + case 'codex': + return handoffCodexStep.run(next, provider) + case 'agents-md': + return handoffAgentsMdStep.run(next, provider) + case 'wizard': + return handoffWizardStep.run(next, provider) + } + }, +} diff --git a/packages/cli/src/commands/init/steps/install-forge.ts b/packages/cli/src/commands/init/steps/install-deps.ts similarity index 69% rename from packages/cli/src/commands/init/steps/install-forge.ts rename to packages/cli/src/commands/init/steps/install-deps.ts index 53b45188..073b95d2 100644 --- a/packages/cli/src/commands/init/steps/install-forge.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -9,26 +9,38 @@ import { } from '../utils.js' const STACK_PACKAGE = '@cipherstash/stack' -const FORGE_PACKAGE = 'stash' +const CLI_PACKAGE = 'stash' -export const installForgeStep: InitStep = { - id: 'install-forge', - name: 'Install stack dependencies', +/** + * Install the runtime + dev npm packages the user needs to run encryption: + * + * - `@cipherstash/stack` (prod) — the encryption client and per-integration + * helpers (drizzle, supabase, schema). + * - `stash` (dev) — the CLI itself, so the user can run `stash db install`, + * `stash wizard`, etc. as a project script without the global install. + * + * Skips silently when both are already present. Prompts before running the + * install commands so the user sees the package manager invocation that's + * about to execute. + */ +export const installDepsStep: InitStep = { + id: 'install-deps', + name: 'Install dependencies', async run(state: InitState, _provider: InitProvider): Promise { const stackPresent = isPackageInstalled(STACK_PACKAGE) - const forgePresent = isPackageInstalled(FORGE_PACKAGE) + const cliPresent = isPackageInstalled(CLI_PACKAGE) // Both already there — silent success, no prompts. - if (stackPresent && forgePresent) { + if (stackPresent && cliPresent) { p.log.success( - `${STACK_PACKAGE} and ${FORGE_PACKAGE} are already installed.`, + `${STACK_PACKAGE} and ${CLI_PACKAGE} are already installed.`, ) - return { ...state, stackInstalled: true, forgeInstalled: true } + return { ...state, stackInstalled: true, cliInstalled: true } } const pm = detectPackageManager() const prodPackages = stackPresent ? [] : [STACK_PACKAGE] - const devPackages = forgePresent ? [] : [FORGE_PACKAGE] + const devPackages = cliPresent ? [] : [CLI_PACKAGE] const commands = combinedInstallCommands(pm, prodPackages, devPackages) const missingList = [ @@ -51,7 +63,7 @@ export const installForgeStep: InitStep = { return { ...state, stackInstalled: stackPresent, - forgeInstalled: forgePresent, + cliInstalled: cliPresent, } } @@ -84,7 +96,7 @@ export const installForgeStep: InitStep = { return { ...state, stackInstalled: stackPresent || allSucceeded, - forgeInstalled: forgePresent || allSucceeded, + cliInstalled: cliPresent || allSucceeded, } }, } diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts new file mode 100644 index 00000000..9ae2de13 --- /dev/null +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -0,0 +1,82 @@ +import * as p from '@clack/prompts' +import { installCommand } from '../../db/install.js' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { CancelledError } from '../types.js' +import { isPackageInstalled } from '../utils.js' + +/** + * Run `stash db install` programmatically after a y/N confirm. + * + * EQL is the Postgres extension every CipherStash query relies on. Without + * it, the encryption client can't read or write to encrypted columns. + * Skipping isn't a dead end — the action prompt fed to the agent will note + * it as the first thing to run before any migration. + * + * We pass the URL we already resolved at the start of init (state.databaseUrl) + * through to `installCommand` so the user is never re-prompted. The installer + * picks the Supabase migration / direct mode itself based on `--supabase` and + * project layout — we don't pre-decide it here. + * + * `installCommand` may `process.exit(1)` on a hard failure (mutually-exclusive + * flag clash, scaffold cancellation). That's fine — by that point the user + * has already authenticated and written the encryption client, and a clean + * exit is preferable to a half-installed setup. + */ +export const installEqlStep: InitStep = { + id: 'install-eql', + name: 'Install EQL extension', + async run(state: InitState, provider: InitProvider): Promise { + const integration = state.integration ?? 'postgresql' + const supabase = integration === 'supabase' || provider.name === 'supabase' + const drizzle = integration === 'drizzle' || provider.name === 'drizzle' + + const proceed = await p.confirm({ + message: + 'Install the EQL extension into your database now? (required for encryption)', + initialValue: true, + }) + + if (p.isCancel(proceed)) throw new CancelledError() + + if (!proceed) { + p.log.info('Skipping EQL installation.') + p.note( + 'Run `stash db install` before applying any migration that references encrypted columns.', + 'EQL not installed', + ) + return { ...state, eqlInstalled: false } + } + + // installCommand scaffolds stash.config.ts (which `import`s from `stash`) + // and immediately loads it via jiti. If `stash` isn't actually loadable + // from the project, that load throws `Cannot find module 'stash'` from + // deep inside jiti — confusing and fatal mid-flow. Detect the precondition + // and bail with a clear message instead. install-deps is what installs + // the package, so a "no" there leaves us here. + if (!isPackageInstalled('stash')) { + p.log.error( + '`stash` is not installed in this project. The previous step (install-deps) was skipped or failed. Re-run `stash init` and accept the dependency install when prompted, or install it manually:', + ) + p.note( + ' npm install --save-dev stash\n pnpm add -D stash\n yarn add -D stash\n bun add -D stash', + 'Then re-run init', + ) + return { ...state, eqlInstalled: false } + } + + try { + await installCommand({ + supabase: supabase || undefined, + drizzle: drizzle || undefined, + databaseUrl: state.databaseUrl, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + p.log.error(`EQL install failed: ${message}`) + p.note('Re-run with: stash db install', 'You can retry manually') + return { ...state, eqlInstalled: false } + } + + return { ...state, eqlInstalled: true } + }, +} diff --git a/packages/cli/src/commands/init/steps/resolve-database.ts b/packages/cli/src/commands/init/steps/resolve-database.ts new file mode 100644 index 00000000..72df5a90 --- /dev/null +++ b/packages/cli/src/commands/init/steps/resolve-database.ts @@ -0,0 +1,30 @@ +import { resolveDatabaseUrl } from '../../../config/database-url.js' +import type { InitProvider, InitState, InitStep } from '../types.js' + +/** + * Resolve the project's `DATABASE_URL` and stash it on init state. + * + * Delegates to `resolveDatabaseUrl()` (the same resolver `stash.config.ts` + * uses), which walks: `--database-url` flag → `process.env.DATABASE_URL` → + * `supabase status` → interactive prompt → hard fail. We pass `supabase: true` + * when the project clearly is one so the resolver tries the Supabase CLI + * even if the user hasn't passed `--supabase`. + * + * The resolver `process.exit(1)`s on failure with an actionable message, so + * this step either produces a valid URL or stops the program cleanly. Every + * downstream init step that needs DB access (build-schema introspection, + * install-eql) reads `state.databaseUrl` rather than calling the resolver + * again — one prompt, one failure mode. + */ +export const resolveDatabaseStep: InitStep = { + id: 'resolve-database', + name: 'Resolve database URL', + async run(state: InitState, _provider: InitProvider): Promise { + // The provider name carries the integration flag the user passed at the + // CLI (`--supabase` → 'supabase'), which lets the resolver try + // `supabase status` even before we've inspected the project layout. + const supabaseHint = _provider.name === 'supabase' + const databaseUrl = await resolveDatabaseUrl({ supabase: supabaseHint }) + return { ...state, databaseUrl } + }, +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 75f491c4..6e957fc8 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -1,3 +1,4 @@ +import type { AgentEnvironment } from './detect-agents.js' import type { PackageManager } from './utils.js' export type Integration = 'drizzle' | 'supabase' | 'postgresql' @@ -17,12 +18,38 @@ export interface SchemaDef { columns: ColumnDef[] } +export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' + export interface InitState { authenticated?: boolean + /** Resolved DATABASE_URL. Set by resolve-database; threaded into every + * downstream step that needs DB access. Never logged or echoed. */ + databaseUrl?: string clientFilePath?: string schemaGenerated?: boolean + /** True when the encryption schema was sourced from live DB introspection + * rather than the placeholder. Drives messaging in the action prompt. */ + schemaFromIntrospection?: boolean stackInstalled?: boolean - forgeInstalled?: boolean + /** True when the `stash` CLI is in the project's devDependencies. */ + cliInstalled?: boolean + /** True when EQL was installed (or already-installed) by install-eql. */ + eqlInstalled?: boolean + /** Detected ORM / framework integration. Set by build-schema. */ + integration?: Integration + /** Schema definitions written to the encryption client. Carries every + * table the user picked during introspection (or the single placeholder + * for empty databases). The generated client file is still the canonical + * source for the full set of column types and search ops. */ + schemas?: SchemaDef[] + /** Names of env keys observed in `.env*` files at init time. Never the + * values. Set by build-schema (so the baseline context.json has them); + * read by the handoff steps without re-scanning. */ + envKeys?: string[] + /** Available coding agents in the user's environment. Set by detect-agents. */ + agents?: AgentEnvironment + /** What the user picked at the "how to proceed" step. */ + handoff?: HandoffChoice } export interface InitStep { diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index e7563f6b..403c53f2 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -3,12 +3,20 @@ import { resolve } from 'node:path' import type { Integration, SchemaDef } from './types.js' /** - * Checks if a package is installed in the current project by looking - * for its directory in node_modules. + * Checks if a package is installed and loadable from the current project. + * + * We require both the package directory AND a `package.json` inside it. A + * leftover directory without a manifest (from an aborted install, a previous + * tool that wrote the path before failing, or a workspace symlink whose + * target was removed) was previously treated as installed — that caused + * `installCommand` later in init to load `stash.config.ts` and fail with + * `Cannot find module 'stash'` at the jiti import. Requiring the manifest + * matches what Node's resolver actually needs to load the module. */ export function isPackageInstalled(packageName: string): boolean { const modulePath = resolve(process.cwd(), 'node_modules', packageName) - return existsSync(modulePath) + const manifestPath = resolve(modulePath, 'package.json') + return existsSync(modulePath) && existsSync(manifestPath) } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' @@ -244,23 +252,133 @@ export function generateClientFromSchema( } } -/** Generates an encryption client file with a placeholder schema for getting started. */ -export function generatePlaceholderClient(integration: Integration): string { - const placeholder: SchemaDef = { - tableName: 'users', - columns: [ - { - name: 'email', - dataType: 'string', - searchOps: ['equality', 'freeTextSearch'], - }, - { - name: 'name', - dataType: 'string', - searchOps: ['equality', 'freeTextSearch'], - }, - ], +function generateDrizzleFromSchemas(schemas: SchemaDef[]): string { + const tableDefs = schemas.map((schema) => { + const varName = `${toCamelCase(schema.tableName)}Table` + const schemaVarName = `${toCamelCase(schema.tableName)}Schema` + + const columnDefs = schema.columns.map((col) => { + const opts: string[] = [] + if (col.dataType !== 'string') { + opts.push(`dataType: '${col.dataType}'`) + } + if (col.searchOps.includes('equality')) { + opts.push('equality: true') + } + if (col.searchOps.includes('orderAndRange')) { + opts.push('orderAndRange: true') + } + if (col.searchOps.includes('freeTextSearch')) { + opts.push('freeTextSearch: true') + } + + const tsType = drizzleTsType(col.dataType) + const optsStr = + opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' + return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` + }) + + return `export const ${varName} = pgTable('${schema.tableName}', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), +${columnDefs.join('\n')} + createdAt: timestamp('created_at').defaultNow(), +}) + +const ${schemaVarName} = extractEncryptionSchema(${varName})` + }) + + const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) + + return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' +import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' +import { Encryption } from '@cipherstash/stack' + +${tableDefs.join('\n\n')} + +export const encryptionClient = await Encryption({ + schemas: [${schemaVarNames.join(', ')}], +}) +` +} + +function generateGenericFromSchemas(schemas: SchemaDef[]): string { + const tableDefs = schemas.map((schema) => { + const varName = `${toCamelCase(schema.tableName)}Table` + + const columnDefs = schema.columns.map((col) => { + const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] + + if (col.dataType !== 'string') { + parts.push(`.dataType('${col.dataType}')`) + } + + for (const op of col.searchOps) { + parts.push(`.${op}()`) + } + + return `${parts.join('\n ')},` + }) + + return `export const ${varName} = encryptedTable('${schema.tableName}', { +${columnDefs.join('\n')} +})` + }) + + const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) + + return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' +import { Encryption } from '@cipherstash/stack' + +${tableDefs.join('\n\n')} + +export const encryptionClient = await Encryption({ + schemas: [${tableVarNames.join(', ')}], +}) +` +} + +/** + * Generate the encryption client file contents for one or more schemas. + * + * The single-schema variants above are kept for the placeholder path (which + * is always exactly one table); this is the variant that renders a real + * multi-table client from DB introspection. + */ +export function generateClientFromSchemas( + integration: Integration, + schemas: SchemaDef[], +): string { + switch (integration) { + case 'drizzle': + return generateDrizzleFromSchemas(schemas) + case 'supabase': + case 'postgresql': + return generateGenericFromSchemas(schemas) } +} - return generateClientFromSchema(integration, placeholder) +/** + * Schema definition we ship as the "fresh project" placeholder. Exported + * separately so steps that follow `build-schema` (gather-context, handoff) + * can read it back without re-parsing the generated client file. + */ +export const PLACEHOLDER_SCHEMA: SchemaDef = { + tableName: 'users', + columns: [ + { + name: 'email', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + { + name: 'name', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + ], +} + +/** Generates an encryption client file with a placeholder schema for getting started. */ +export function generatePlaceholderClient(integration: Integration): string { + return generateClientFromSchema(integration, PLACEHOLDER_SCHEMA) } diff --git a/packages/cli/src/commands/schema/build.ts b/packages/cli/src/commands/schema/build.ts index f2435e07..3e5ae79c 100644 --- a/packages/cli/src/commands/schema/build.ts +++ b/packages/cli/src/commands/schema/build.ts @@ -1,337 +1,10 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' -import pg from 'pg' import { loadStashConfig } from '../../config/index.js' - -type Integration = 'drizzle' | 'supabase' | 'postgresql' -type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' -type SearchOp = 'equality' | 'orderAndRange' | 'freeTextSearch' - -interface ColumnDef { - name: string - dataType: DataType - searchOps: SearchOp[] -} - -interface SchemaDef { - tableName: string - columns: ColumnDef[] -} - -interface DbColumn { - columnName: string - dataType: string - udtName: string - isEqlEncrypted: boolean -} - -interface DbTable { - tableName: string - columns: DbColumn[] -} - -// --- Database introspection --- - -function pgTypeToDataType(udtName: string): DataType { - switch (udtName) { - case 'int2': - case 'int4': - case 'int8': - case 'float4': - case 'float8': - case 'numeric': - return 'number' - case 'bool': - return 'boolean' - case 'date': - case 'timestamp': - case 'timestamptz': - return 'date' - case 'json': - case 'jsonb': - return 'json' - default: - return 'string' - } -} - -async function introspectDatabase(databaseUrl: string): Promise { - const client = new pg.Client({ connectionString: databaseUrl }) - try { - await client.connect() - - const { rows } = await client.query<{ - table_name: string - column_name: string - data_type: string - udt_name: string - }>(` - SELECT c.table_name, c.column_name, c.data_type, c.udt_name - FROM information_schema.columns c - JOIN information_schema.tables t - ON t.table_name = c.table_name AND t.table_schema = c.table_schema - WHERE c.table_schema = 'public' - AND t.table_type = 'BASE TABLE' - ORDER BY c.table_name, c.ordinal_position - `) - - const tableMap = new Map() - for (const row of rows) { - const cols = tableMap.get(row.table_name) ?? [] - cols.push({ - columnName: row.column_name, - dataType: row.data_type, - udtName: row.udt_name, - isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', - }) - tableMap.set(row.table_name, cols) - } - - return Array.from(tableMap.entries()).map(([tableName, columns]) => ({ - tableName, - columns, - })) - } finally { - await client.end() - } -} - -// --- Code generation --- - -function toCamelCase(str: string): string { - return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) -} - -function drizzleTsType(dataType: string): string { - switch (dataType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'date': - return 'Date' - case 'json': - return 'Record' - default: - return 'string' - } -} - -function generateClientFromSchemas( - integration: Integration, - schemas: SchemaDef[], -): string { - switch (integration) { - case 'drizzle': - return generateDrizzleClient(schemas) - case 'supabase': - case 'postgresql': - return generateGenericClient(schemas) - } -} - -function generateDrizzleClient(schemas: SchemaDef[]): string { - const tableDefs = schemas.map((schema) => { - const varName = `${toCamelCase(schema.tableName)}Table` - const schemaVarName = `${toCamelCase(schema.tableName)}Schema` - - const columnDefs = schema.columns.map((col) => { - const opts: string[] = [] - if (col.dataType !== 'string') { - opts.push(`dataType: '${col.dataType}'`) - } - if (col.searchOps.includes('equality')) { - opts.push('equality: true') - } - if (col.searchOps.includes('orderAndRange')) { - opts.push('orderAndRange: true') - } - if (col.searchOps.includes('freeTextSearch')) { - opts.push('freeTextSearch: true') - } - - const tsType = drizzleTsType(col.dataType) - const optsStr = - opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' - return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` - }) - - return `export const ${varName} = pgTable('${schema.tableName}', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), -${columnDefs.join('\n')} - createdAt: timestamp('created_at').defaultNow(), -}) - -const ${schemaVarName} = extractEncryptionSchema(${varName})` - }) - - const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) - - return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' -import { Encryption } from '@cipherstash/stack' - -${tableDefs.join('\n\n')} - -export const encryptionClient = await Encryption({ - schemas: [${schemaVarNames.join(', ')}], -}) -` -} - -function generateGenericClient(schemas: SchemaDef[]): string { - const tableDefs = schemas.map((schema) => { - const varName = `${toCamelCase(schema.tableName)}Table` - - const columnDefs = schema.columns.map((col) => { - const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] - - if (col.dataType !== 'string') { - parts.push(`.dataType('${col.dataType}')`) - } - - for (const op of col.searchOps) { - parts.push(`.${op}()`) - } - - return `${parts.join('\n ')},` - }) - - return `export const ${varName} = encryptedTable('${schema.tableName}', { -${columnDefs.join('\n')} -})` - }) - - const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`) - - return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' - -${tableDefs.join('\n\n')} - -export const encryptionClient = await Encryption({ - schemas: [${tableVarNames.join(', ')}], -}) -` -} - -// --- Shared helpers --- - -function allSearchOps(dataType: DataType): SearchOp[] { - const ops: SearchOp[] = ['equality', 'orderAndRange'] - if (dataType === 'string') { - ops.push('freeTextSearch') - } - return ops -} - -// --- Database-driven schema builder --- - -async function selectTableColumns( - tables: DbTable[], -): Promise { - const selectedTable = await p.select({ - message: 'Which table do you want to encrypt columns in?', - options: tables.map((t) => { - const eqlCount = t.columns.filter((c) => c.isEqlEncrypted).length - const hint = - eqlCount > 0 - ? `${t.columns.length} columns, ${eqlCount} already encrypted` - : `${t.columns.length} column${t.columns.length !== 1 ? 's' : ''}` - return { value: t.tableName, label: t.tableName, hint } - }), - }) - - if (p.isCancel(selectedTable)) return undefined - - const table = tables.find((t) => t.tableName === selectedTable)! - const eqlColumns = table.columns.filter((c) => c.isEqlEncrypted) - - if (eqlColumns.length > 0) { - p.log.info( - `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`, - ) - } - - const selectedColumns = await p.multiselect({ - message: `Which columns in "${selectedTable}" should be in the encryption schema?`, - options: table.columns.map((col) => ({ - value: col.columnName, - label: col.columnName, - hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType, - })), - required: true, - initialValues: eqlColumns.map((c) => c.columnName), - }) - - if (p.isCancel(selectedColumns)) return undefined - - const searchable = await p.confirm({ - message: - 'Enable searchable encryption on these columns? (you can fine-tune indexes later)', - initialValue: true, - }) - - if (p.isCancel(searchable)) return undefined - - const columns: ColumnDef[] = selectedColumns.map((colName) => { - const dbCol = table.columns.find((c) => c.columnName === colName)! - const dataType = pgTypeToDataType(dbCol.udtName) - const searchOps = searchable ? allSearchOps(dataType) : [] - return { name: colName, dataType, searchOps } - }) - - p.log.success( - `Schema defined: ${selectedTable} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, - ) - - return { tableName: selectedTable, columns } -} - -async function buildSchemasFromDatabase( - databaseUrl: string, -): Promise { - const s = p.spinner() - s.start('Connecting to database and reading schema...') - - let tables: DbTable[] - try { - tables = await introspectDatabase(databaseUrl) - } catch (error) { - s.stop('Failed to connect to database.') - p.log.error(error instanceof Error ? error.message : 'Unknown error') - return undefined - } - - if (tables.length === 0) { - s.stop('No tables found in the public schema.') - return undefined - } - - s.stop( - `Found ${tables.length} table${tables.length !== 1 ? 's' : ''} in the public schema.`, - ) - - const schemas: SchemaDef[] = [] - - while (true) { - const schema = await selectTableColumns(tables) - if (!schema) return undefined - - schemas.push(schema) - - const addMore = await p.confirm({ - message: 'Encrypt columns in another table?', - initialValue: false, - }) - - if (p.isCancel(addMore)) return undefined - if (!addMore) break - } - - return schemas -} +import { buildSchemasFromDatabase } from '../init/lib/introspect.js' +import type { Integration } from '../init/types.js' +import { generateClientFromSchemas } from '../init/utils.js' // --- Command --- diff --git a/packages/cli/src/commands/wizard/index.ts b/packages/cli/src/commands/wizard/index.ts index 0b663b99..88d1d05e 100644 --- a/packages/cli/src/commands/wizard/index.ts +++ b/packages/cli/src/commands/wizard/index.ts @@ -28,20 +28,17 @@ function splitRunner(cmd: string): { bin: string; preArgs: string[] } { } /** - * Thin wrapper around `@cipherstash/wizard`. + * Spawn `@cipherstash/wizard` and return its exit code. * - * The wizard ships as its own package so the heavy agent SDK stays out of the - * `stash` CLI bundle. This wrapper exists so users see one CLI surface - * (`stash wizard`) instead of being told to remember a second tool name. - * - * On a cold cache (the wizard package isn't installed in the project) the - * package manager will download it before running — that can take a few - * seconds. We surface that explicitly so the user doesn't think the CLI is - * hung. We don't show a spinner because the wizard itself uses clack and - * needs an inherited TTY; intercepting child stdout would break the wizard's - * own UI. + * The wizard ships as its own package so the heavy agent SDK stays out of + * the `stash` CLI bundle. Returning the exit code (rather than calling + * `process.exit`) lets callers decide whether to abort: the top-level + * `stash wizard` subcommand exits the process; the `init` handoff path + * keeps init alive so it can run its outro, log final state, etc. */ -export async function wizardCommand(passthroughArgs: string[]): Promise { +export async function runWizardSpawn( + passthroughArgs: string[], +): Promise { const pm = detectPackageManager() const runner = runnerCommand(pm, WIZARD_PACKAGE) const cached = isPackageInstalled(WIZARD_PACKAGE) @@ -57,7 +54,7 @@ export async function wizardCommand(passthroughArgs: string[]): Promise { const { bin, preArgs } = splitRunner(runner) const args = [...preArgs, ...passthroughArgs] - const exitCode = await new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const child = spawn(bin, args, { stdio: 'inherit', shell: false }) child.on('close', (code) => resolvePromise(code ?? 0)) child.on('error', (err) => { @@ -65,7 +62,16 @@ export async function wizardCommand(passthroughArgs: string[]): Promise { resolvePromise(127) }) }) +} +/** + * Top-level `stash wizard` subcommand. Spawns the wizard and exits with + * its exit code so users see the wizard's failure state directly. For the + * in-process `init` handoff that wants to preserve init's lifecycle, call + * `runWizardSpawn` instead. + */ +export async function wizardCommand(passthroughArgs: string[]): Promise { + const exitCode = await runWizardSpawn(passthroughArgs) if (exitCode !== 0) { process.exit(exitCode) } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e4b46883..0a85deea 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,4 +1,4 @@ -import { cpSync } from 'node:fs' +import { cpSync, existsSync } from 'node:fs' import { defineConfig } from 'tsup' export default defineConfig([ @@ -21,6 +21,20 @@ export default defineConfig([ onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package cpSync('src/sql', 'dist/sql', { recursive: true }) + // Skills live at the monorepo root and ship inside the CLI tarball so + // `stash init` can copy them into the user's `.claude/skills/` or + // `.codex/skills/` directory at handoff time. Mirror of + // packages/wizard/tsup.config.ts:24. + if (existsSync('../../skills')) { + cpSync('../../skills', 'dist/skills', { recursive: true }) + } + // The AGENTS.md doctrine fragment is read at handoff time and + // wrapped in a sentinel block. The runtime resolver in + // src/commands/init/lib/build-agents-md.ts walks up looking for a + // sibling `doctrine/` dir, so mirror the source layout under dist. + cpSync('src/commands/init/doctrine', 'dist/commands/init/doctrine', { + recursive: true, + }) }, }, {