From 1a9d1909ec8bc07d76e10bd2e3b5f41944724895 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 16:09:52 +1000 Subject: [PATCH 1/3] docs(cli): refresh the bundled stash-cli skill against the current CLI surface The `stash-cli` skill ships inside the `stash` tarball (`dist/skills/`) and is copied into the user's `.claude/skills/` / `.codex/skills/`, or inlined into their `AGENTS.md`, at handoff time. A stale skill becomes stale guidance in the user's project, so treat it as a shipped artifact rather than internal docs. Rewritten (673 -> 521 lines) while adding four previously undocumented commands: - New `Start here` and `Authentication` sections. Setup is driven through the CLI: agents read `stash manifest --json` first, then trigger `stash auth login --json` and surface the verification URL for a human to approve, then run `stash init`. Authenticating before `init` matters -- init's auth step takes the interactive path and would otherwise try to open a browser on the agent's host. - New `Never read these` invariant, mirrored into the AGENTS.md doctrine: never read `~/.cipherstash/secretkey.json`, `~/.cipherstash/auth.json`, anything under `~/.cipherstash/workspaces/`, or `.env*`. The wizard already blocks these paths in code (`agent/interface.ts`), but the Claude Code, Codex and AGENTS.md targets had no written rule. - Documents `manifest`, `doctor`, `wizard` and `auth regions`, plus the non-interactive interface: per-command escape hatches (there is no global `--non-interactive`/`--yes`/`--json`), exit codes, the DATABASE_URL resolution order, and the `auth login --json` NDJSON event contract. - Corrects the db -> eql move. `db install`, `db upgrade` and `db status` are deprecated aliases that warn and forward; `db push`, `db activate`, `db validate`, `db test-connection` and `db migrate` remain in `db`. - Scopes `db push` / `db activate` as EQL v2 + CipherStash Proxy only, in both the skill and the README's recommended flow. - Adds the missing `--database-url`, `--eql-version`, `--prisma-next`, `--proxy`/`--no-proxy` and `--region` flags; corrects six programmatic API signatures; repoints the closed #447 citations at #585; and marks `stash env` as the non-functional stub it currently is. Also fixes the README's claim that `stash init` ends in a four-option agent handoff menu -- init is mechanical-only and chain-prompts to `stash plan`; the handoff belongs to `plan` / `impl`. Verified by diffing every command and flag in the skill against `stash manifest --json`, and by exercising the documented `auth login --json`, `auth regions --json`, `db status` deprecation and `env` stub behaviours. --- .changeset/stash-cli-skill-refresh.md | 31 + packages/cli/README.md | 23 +- .../commands/init/doctrine/AGENTS-doctrine.md | 4 +- skills/stash-cli/SKILL.md | 760 +++++++----------- 4 files changed, 352 insertions(+), 466 deletions(-) create mode 100644 .changeset/stash-cli-skill-refresh.md diff --git a/.changeset/stash-cli-skill-refresh.md b/.changeset/stash-cli-skill-refresh.md new file mode 100644 index 000000000..7a65d45c8 --- /dev/null +++ b/.changeset/stash-cli-skill-refresh.md @@ -0,0 +1,31 @@ +--- +"stash": patch +--- + +Refresh the bundled `stash-cli` agent skill and the CLI README against the current +command surface. The skills directory ships inside the `stash` tarball and is copied +into the user's `.claude/skills/` / `.codex/skills/` (or inlined into `AGENTS.md`) at +handoff time, so a stale skill becomes stale guidance in the user's project. + +- **New `Start here` and `Authentication` sections.** Setup is driven through the CLI: + agents read `stash manifest --json` first, then trigger `stash auth login --json` and + surface the verification URL for a human to approve, then run `stash init`. Authenticating + before `init` matters — `init`'s auth step is interactive and would otherwise try to open + a browser on the agent's host. +- **New `Never read these` invariant**, mirrored into the `AGENTS.md` doctrine: agents must + never read `~/.cipherstash/secretkey.json`, `~/.cipherstash/auth.json`, anything under + `~/.cipherstash/workspaces/`, or `.env*`. The wizard already blocks these paths in code; + the other handoff targets had no written rule. +- **Documents `manifest`, `doctor`, `wizard`, and `auth regions`**, which the skill omitted + entirely, plus the non-interactive interface (per-command escape hatches, exit codes, the + `DATABASE_URL` resolution order, the `auth login --json` NDJSON event contract). +- **Corrects the `db` → `eql` move.** `db install`, `db upgrade`, and `db status` are + deprecated aliases that warn and forward; `db push`, `db activate`, `db validate`, + `db test-connection`, and `db migrate` remain in the `db` group. +- **Scopes `db push` / `db activate` as EQL v2 + CipherStash Proxy only**, in both the skill + and the README's recommended flow. SDK users hold their encryption config in application + code and don't need them. +- Adds the missing `--database-url`, `--eql-version`, `--prisma-next`, `--proxy`/`--no-proxy`, + and `--region` flags; corrects six programmatic API signatures; fixes the README's claim + that `stash init` ends in an agent-handoff menu (that belongs to `stash plan` / `stash impl`); + and marks `stash env` as the non-functional stub it currently is. diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f7230076..6c81d5462 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,19 +12,23 @@ 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, introspect, install EQL, hand off to your agent +npx stash init # scaffold, introspect, install EQL ``` -`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: +`stash init` does the scaffold-once work as one flow: authenticate, resolve `DATABASE_URL`, choose Proxy or direct SDK access, introspect your database and scaffold an encryption client, install dependencies, install the EQL extension, and write `.cipherstash/context.json`. It stops there, at a clean save-point, and offers to continue into `stash plan`. + +The agent handoff belongs to the next two commands — `stash plan` drafts a reviewable `.cipherstash/plan.md`, and `stash impl` executes it. Both present the same four targets: - **Hand off to Claude Code** — copies the per-integration set of skills (`stash-encryption`, `stash-`, `stash-cli`) into `.claude/skills/`, writes `.cipherstash/context.json` and `setup-prompt.md`, then launches `claude` interactively. - **Hand off to Codex** — copies the same skills into `.codex/skills/`, writes a sentinel-managed `AGENTS.md` (durable doctrine), plus `.cipherstash/` context files, then launches `codex`. - **Use the CipherStash Agent** — runs the in-house wizard (`@cipherstash/wizard`). - **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, and stops. -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`. +Pass `--target ` to skip the picker. **It is required when running `plan` or `impl` non-interactively** (CI, pipes, an agent's shell) — the picker reads from `/dev/tty`, so without it the command prints a hint and exits without handing off. + +A project-specific action plan is written to `.cipherstash/setup-prompt.md` regardless of which target 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. +If neither `claude` nor `codex` is on PATH, the handoff still writes the rules files and prints install instructions — your progress is never wasted. --- @@ -32,12 +36,15 @@ If neither `claude` nor `codex` is on PATH, init still writes the rules files an ``` npx stash auth login - └── 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 + └── npx stash init ← introspects DB, installs EQL, writes context.json + └── npx stash plan ← drafts .cipherstash/plan.md for review + └── npx stash impl ← agent edits schema files / generates migrations + └── npx stash status ← where am I? ``` -`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. +`stash` covers authentication, initialization, EQL install/upgrade/status, schema introspection, the encryption rollout and cutover commands, 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. + +> `stash db push` is **not** part of the default flow. It registers the encryption config in `public.eql_v2_configuration`, which only [CipherStash Proxy](https://github.com/cipherstash/proxy) reads. SDK users (Drizzle, Supabase, plain PostgreSQL) keep that config in application code and can skip it. --- diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md index 6d6a464e0..eee2ddc24 100644 --- a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -15,10 +15,10 @@ This document is the **durable rule book** for any agent working on this codebas 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. +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. Never read, `cat`, `grep`, or echo `~/.cipherstash/secretkey.json` (the development key), `~/.cipherstash/auth.json` (OAuth token and JWTs), anything under `~/.cipherstash/workspaces/`, or any `.env*` file. The CLI reads these itself — no command needs you to open them. If a command fails on authentication, re-run `stash auth login` rather than inspecting the profile. 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 eql upgrade`, not a hand-edit). +6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash eql upgrade`, not a hand-edit). 7. **`@cipherstash/stack` must be excluded from any bundler.** The package wraps a native FFI module (`@cipherstash/protect-ffi`) that cannot be bundled. The moment you `npm install @cipherstash/stack` in a project with a bundler, configure the exclusion *before* writing any code that imports it. Concretely: Next.js needs `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` in `next.config.{js,ts,mjs}`; webpack needs `externals` entries; esbuild needs `external`; Vite SSR needs `ssr.external`. Skipping this surfaces as `Cannot find module '@cipherstash/protect-ffi-*'` at runtime, often after the user has shipped to production. If you're declaring an encrypted column for the first time in a project, treat configuring this exclusion as part of the same change. ## Migrations — three phases, always reversible diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index ccf0509f4..8eff9506c 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -1,673 +1,521 @@ --- name: stash-cli -description: Configure and use the `stash` package for project initialization, EQL database setup, encryption schema management, and Supabase integration. Replaces the legacy `@cipherstash/stack-forge` skill. The AI wizard is now a separate package (`@cipherstash/wizard`). +description: Drive CipherStash setup and encryption migrations through the `stash` CLI — `init`, `plan`, `impl`, `status`, `auth login`, `eql install/upgrade/status`, `db push/validate`, `encrypt backfill/cutover/drop`, `schema build`, and `manifest --json`. Covers the agent / non-interactive interface, the credential rules for `~/.cipherstash`, and the rollout-then-cutover lifecycle. Use when setting up CipherStash EQL in a database, running any `stash` command, creating `stash.config.ts`, or rolling encryption out to production. --- -# CipherStash CLI +# CipherStash CLI (`stash`) -Configure and use `stash` for project initialization, EQL database setup, encryption schema management, and Supabase integration. Previously published as `@cipherstash/stack-forge`; the `stash-forge` binary is now consolidated under `stash`. The AI-powered wizard formerly bundled here lives in [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard). +`stash` is the **dev-time CLI** for CipherStash. It owns authentication, project setup, the EQL (Encrypted Query Language) Postgres extension, and the machinery that migrates an existing plaintext column to an encrypted one. Its runtime counterpart is `@cipherstash/stack`, which encrypts and decrypts values in your application. -## Trigger - -Use this skill when: -- The user asks about setting up CipherStash EQL in a database -- Code imports `stash` (or legacy `@cipherstash/stack-forge`) -- A `stash.config.ts` file exists or needs to be created -- The user wants to install, configure, or manage the EQL extension in PostgreSQL -- The user is using any of the setup-lifecycle commands: `init`, `plan`, `impl`, `status` -- The user mentions "stash CLI", "stash db", "stack-forge", "stash-forge", "EQL install", or "encryption schema" -- The user has a `.cipherstash/` directory with `context.json`, `plan.md`, or `setup-prompt.md` - -Do NOT trigger when: -- The user is working with `@cipherstash/stack` (the runtime SDK) without needing database setup -- The user is running the AI wizard — that's `@cipherstash/wizard`, a separate package -- General PostgreSQL questions unrelated to CipherStash - -## What is stash? +Think Prisma Migrate or Drizzle Kit: a dev-time tool that prepares the database, while the runtime SDK handles queries. -`stash` is a **dev-time CLI and TypeScript library** for managing CipherStash EQL (Encrypted Query Language) in PostgreSQL databases. It is a companion to the `@cipherstash/stack` runtime SDK — it handles project setup and database tooling during development while `@cipherstash/stack` handles runtime encryption/decryption operations. +**All setup and migration work is driven through this CLI.** Don't hand-write EQL SQL, don't hand-edit `.cipherstash/`, and don't introspect the database yourself. The CLI owns that state; hand-edits desync it. -Think of it like Prisma Migrate or Drizzle Kit: a dev-time tool that prepares your database while the runtime SDK handles queries. +## Trigger -The binary is named `stash`. Top-level commands: `init`, `plan`, `impl`, `status`, `auth`, `db`, `schema`, `env`. +Use this skill when: -### Setup lifecycle (the recommended flow) +- The user wants to set up CipherStash or install EQL in a PostgreSQL database. +- Any `stash` command is being run: `init`, `plan`, `impl`, `status`, `auth`, `eql`, `db`, `encrypt`, `schema`, `manifest`, `doctor`, `wizard`, `env`. +- A `stash.config.ts` file exists or needs to be created. +- A `.cipherstash/` directory exists (`context.json`, `plan.md`, `migrations.json`, `setup-prompt.md`). +- The user mentions "stash CLI", "EQL install", "encryption schema", or an encryption rollout/cutover. -The setup lifecycle is split across four explicit save-points. Each command can be run standalone, but the chain prompts make first-time setup a single flow: +Do **not** trigger when: -| Command | Owns | Ends with | -|---------|------|-----------| -| `stash init` | Auth, database, dep install, EQL install, encryption client scaffold, `.cipherstash/context.json` | Default-yes prompt → chains to `stash plan` | -| `stash plan` | Drafts `.cipherstash/plan.md` via agent handoff. State-driven — auto-detects whether to plan an encryption rollout or an encryption cutover. | Default-yes prompt → chains to `stash impl` | -| `stash impl` | Executes the plan via agent handoff. Refuses cutover-step plans without a recorded `dual_writing` event; prints the deploy-gate banner after a rollout-step run. | Deploy-gate banner (rollout) or "verify state" (cutover/new) | -| `stash status` | The encryption-rollout quest log — per-column "where am I" map, runs in ms | — | +- Working with `@cipherstash/stack` (the runtime SDK) with no database setup involved — see the `stash-encryption` skill. +- Running the AI wizard directly — that's `@cipherstash/wizard`, a separate package. +- General PostgreSQL questions unrelated to CipherStash. -### Running from automation (non-TTY) +## Start here -If you're invoking `stash plan` or `stash impl` from a non-TTY context — CI, a pipe, or an agent's Bash tool — **always pass `--target `**. Both commands present an interactive agent-target picker that reads from `/dev/tty`; without `--target` they print a "no agent selected" hint and exit 0 without performing the handoff. With `--target`, the picker is skipped and the named handoff runs non-interactively. +The entry point, for humans and agents alike: ```bash -stash plan --target claude-code -stash impl --target agents-md +npx stash init # PostgreSQL / Drizzle / Prisma +npx stash init --supabase # Supabase ``` -`stash init` and `stash status` are safe to call from any context — they detect non-TTY and adapt automatically. - -### Rolling encryption out to production +`stash init` installs the CLI as a project dev dependency, so subsequent commands can drop the `npx`. The CLI is package-manager aware — before init, use whichever one-shot runner your project uses (`npx`, `pnpm dlx`, `bunx`, `yarn dlx`). -Two paths to a fully-encrypted column: - -- **New encrypted column** — declared encrypted from the start. Single deploy. Use the `stash plan` → `stash impl` chain straight through. -- **Existing column with live data** — split across two passes around a hard production-deploy gate. - -For migrate columns, the flow is: +**If you are an agent, do this first:** -1. **`stash plan`** detects that no `dual_writing` event is recorded and writes an encryption-rollout plan: schema-add for the encrypted twin and the application-side dual-write code. (If using CipherStash Proxy, the plan also includes `stash db push` to register the pending config.) -2. **`stash impl`** executes that plan and stops with a deploy-gate banner. Encrypted values are not flowing yet — the dual-write code has to be running in production before backfill is safe. -3. **You ship and deploy** the rollout PR. -4. **`stash status`** confirms dual-writes are live. -5. **`stash plan`** detects `dual_writing` and writes a separate cutover plan: backfill, schema rename, cutover, read-path switch, drop. (For Proxy users, the plan also includes `stash db push` after schema rename to register the new shape.) -6. **`stash impl`** executes the cutover. +1. **`npx stash manifest --json`** — the structured, version-stamped command surface. Read it before running anything else. +2. **`npx stash auth login --json --region `** — only if not already authenticated. Surface the URL to the human (see [Authentication](#authentication)). Do this *before* `init`. +3. **`npx stash init`** — now finds the token and proceeds without prompting. +4. **`stash plan` → `stash impl` → `stash status`** — pass `--target` when non-interactive. -The split is invisible to the user — they just keep running `stash plan` and `stash impl`; the CLI knows where they are. +### Ask the CLI, don't trust this file -For users without a deployed application to gate on (local dev, sandboxes, freshly-seeded test DBs), `stash plan --complete-rollout` produces a single end-to-end plan with no deploy gate. The flag prints a default-no confirm with a loud warning before generating; only safe when no production app writes to this database. - -Use `stash status` at any time to see which save-points are complete and what each rollout's next move is. +```bash +npx stash manifest --json # full command surface, stamped with the CLI version +npx stash --help # per-command flags, defaults, env vars, examples +``` -> **Note:** Until issue [#447](https://github.com/cipherstash/stack/issues/447) follow-up lands, `stash encrypt cutover` requires a pending EQL configuration (set by `stash db push`). SDK users must run `stash db push` once before `stash encrypt cutover` to satisfy this precondition. Tracked separately and will be addressed in a follow-up. +`manifest --json` emits `{ name, version, groups[] }`, where each group has a `title` and `commands[]`, and each command carries `name`, `summary`, optional `long` and `examples[]`, and `flags[]` of `{ name, value?, description, default?, env? }`. `version` is the CLI's own, so anything generated from it names the version it describes. It is pure metadata, so it runs even when the native binary is broken. -## Configuration +`stash --help` renders from the same registry: an exact match prints full help, a group prefix (`stash eql`) lists its subcommands. -### 1. Create `stash.config.ts` in the project root +**This file describes intent and lifecycle. The manifest is authoritative for flags.** If the two disagree, the manifest is right and this file is stale. -```typescript -import { defineConfig } from 'stash' +## Authentication -export default defineConfig({ - databaseUrl: process.env.DATABASE_URL!, - client: './src/encryption/index.ts', -}) +```bash +npx stash auth login +npx stash auth regions # discover valid --region values ``` -`eql install` will scaffold this file for you if it's missing. +`auth login` runs an OAuth 2.0 device-code flow: pick a region, approve in a browser, then the device is bound to the workspace's default keyset. Credentials and a development key are written to the `~/.cipherstash` profile. Later commands authenticate from there without a fresh login. -### Config options +**Agents: you can trigger the flow, but only a human can complete it.** -```typescript -type StashConfig = { - databaseUrl: string // Required: PostgreSQL connection string - client?: string // Optional: path to encryption client (default: './src/encryption/index.ts') -} +```bash +npx stash auth login --json --region us-east-1 ``` -- `defineConfig()` provides TypeScript type-checking for the config file. -- `client` points to the encryption client file used by `db push` and `db validate` to load the encryption schema. -- Config is loaded automatically from `stash.config.ts` by walking up from `process.cwd()` (like `tsconfig.json` resolution). -- `.env` files are loaded automatically via `dotenv` before config evaluation. +`--json` emits newline-delimited JSON on stdout, one object per line, and deliberately does *not* open a browser — the human opens the URL, not the agent's host. -## CLI Usage +| Event | Meaning | +|---|---| +| `{ status: "authorization_required", userCode, verificationUri, verificationUriComplete, expiresIn }` | Emitted immediately. **Show `verificationUriComplete` to the user and wait.** | +| `{ status: "authorized", expiresAt, expiresAtIso }` | The human approved. | +| `{ status: "device_bound" }` | Device bound to the default keyset. Done. | +| `{ status: "error", code, message }` | Failure. Exit code 1. | -The primary interface is the `stash` package. `stash init` installs it as a project dev dependency, so after init you invoke commands directly: +**Authenticate before `stash init`.** Init's authenticate step uses the interactive path, so an agent running `init` unauthenticated makes the CLI try to open a browser on the agent's machine — and in a non-TTY it exits with `region_required` unless `--region` or `STASH_REGION` is set. Once a valid token exists, init logs `Using workspace X (region)` and moves on silently. -```bash -stash [options] -``` +Flags: `--region ` (env `STASH_REGION`), `--json`, `--no-open`, `--supabase` / `--drizzle` (referrer tracking only). -Through your package manager (`pnpm exec`, `bun x`, `yarn`, or via `npm run`-style scripts), `stash` resolves to the project-local binary. **Before init has run** — for example when you're scaffolding the very first command — use your package manager's one-shot runner: `bunx stash init`, `pnpm dlx stash init`, `yarn dlx stash init`, or `npx stash init`. The CLI is package-manager-aware; pick whichever your project uses. +## Never read these -### `init` — Scaffold a CipherStash project +The CLI holds your credentials and reads them itself. No command needs you to open them. -```bash -stash init -stash init --supabase -stash init --drizzle -``` +**Never read, `cat`, `grep`, or echo:** -Init is the **scaffold** save-point. It does mechanical setup only — no agent handoff. Six phases, prompts only when it can't make a sensible default: +- `~/.cipherstash/secretkey.json` — the development key +- `~/.cipherstash/auth.json` — OAuth token and JWTs +- anything under `~/.cipherstash/workspaces/` +- `.env`, `.env.local`, `.env.production`, or any credentials file -1. **Authenticate** — only prompts when not already logged in (otherwise logs `Using workspace X (region)` and proceeds). -2. **Resolve database** — picks up `DATABASE_URL` from `.env`/`.env.local` or prompts for it. Verifies the connection. -3. **Build schema** — auto-detects your framework (Drizzle from `drizzle.config.*` / `drizzle-orm` / `drizzle-kit` in `package.json`; Supabase from the `DATABASE_URL` host) and silently writes a placeholder client to `./src/encryption/index.ts`. Only prompts you if a file already exists at that path. -4. **Install dependencies** — single combined prompt for `@cipherstash/stack` and `stash`. Skipped entirely when both are already in `node_modules`. -5. **Install EQL** — runs the equivalent of `stash eql install` against the resolved database (Drizzle migration, Supabase migration, or direct, per detection). Skipped if EQL is already installed. -6. **Gather context** — detects available coding agents (Claude Code, Codex, Cursor, Windsurf, Cline) and writes `.cipherstash/context.json` with integration, package manager, schemas, env keys, and detected agents. +Referring to env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) in code and docs is fine. Their *values* are not. New keys go into `.env.example` as placeholders; ask the user to fill in the real value locally. -When init finishes, it prints a checkmark panel of completed phases and an interactive **chain prompt** (default-yes): *"Continue to `stash plan` now to draft your encryption plan?"* Yes auto-launches `stash plan`. No prints "Next: run `stash plan` to draft your encryption plan." Non-TTY (CI, pipes) skips the prompt and prints the hint. +If a command fails on authentication, re-run `stash auth login`. Do not inspect the profile to diagnose it. -The `--supabase` and `--drizzle` flags tailor the intro message and EQL install variant. File scaffolding uses the same auto-detection regardless. +## Running non-interactively (agents, CI, pipes) -#### Generated files +A command is interactive only when **stdin is a TTY and `CI` is not set** to `1`/`true` (case-insensitive). Otherwise prompts are skipped. -| File | Purpose | -|------|---------| -| `./src/encryption/index.ts` | Placeholder encryption client — edit to declare encrypted columns (or let `stash plan`/`stash impl` do it for you). | -| `.cipherstash/context.json` | Detected facts about the project (integration, pm, schemas, env keys, agents). Read by `plan`, `impl`, and `status`. Never hand-edit. | -| `stash.config.ts` | Scaffolded if missing — points the CLI at `databaseUrl` and the encryption client. | +There is **no global `--non-interactive`, `--yes`, or `--json` flag.** Each command carries its own escape hatch: -### `plan` — Draft a reviewable encryption plan +| Need | Escape hatch | +|---|---| +| Region (`auth login`, `init`) | `--region ` or `STASH_REGION` | +| Database URL (all `db` / `eql` / `schema` commands) | `--database-url ` or `DATABASE_URL` | +| Agent target (`plan`, `impl`) | `--target ` | +| Proxy choice (`init`) | `--proxy` / `--no-proxy` (default) | +| Dual-write confirmation (`encrypt backfill`) | `--confirm-dual-writes-deployed` | +| Machine-readable output | `--json` on `status`, `manifest`, `auth login`, `auth regions` | -```bash -stash plan -stash plan --complete-rollout -stash plan --target claude-code # non-interactive — skip the agent picker -``` +When a required value is missing in a non-TTY context, the command exits non-zero with an actionable message naming the flag and env var — it never hangs. -`plan` is the **draft for review** save-point. Pre-flights `.cipherstash/context.json` (errors with a "Run `stash init` first" pointer if missing). Hands off to a coding agent — all four targets are offered: Claude Code, Codex, AGENTS.md (for Cursor/Windsurf/Cline), and the CipherStash Agent (`@cipherstash/wizard`). +**`plan` and `impl` need `--target` in a non-TTY.** Their agent-target picker reads from `/dev/tty`. Without `--target` they print a "no agent selected" hint and exit 0 *without performing the handoff*. `init` and `status` adapt automatically and are safe anywhere. -`--target ` skips the interactive agent-target picker. **Required when invoking `plan` from a non-TTY context** (CI, pipes, an agent's Bash tool) — without it, `plan` prints a "no agent selected" hint and exits 0 without performing the handoff. In a TTY, `--target` is optional; it just bypasses the picker. +**Exit codes.** `1` on failure; `0` when a user cancels a prompt. In `--json` mode an `{ "status": "error", "code", "message" }` line is emitted before exiting 1. -`plan` is **state-driven**. It reads `.cipherstash/migrations.json` and `cs_migrations` and dispatches to one of three plan templates: +**`stash status --json` has a stable shape** — `{ initialized, planExists, observedFromDb, active[], completed[] }`, each quest carrying `{ table, column, path, title, progress, complete, nextMove, objectives[] }`. It will not change without a major version bump. Prefer it over parsing `--plain`. -| Detected state | Plan written | -|---|---| -| Manifest empty, fresh project, or no `dual_writing` events recorded | **Encryption rollout** — schema-add and dual-write code. (Proxy users also: `stash db push` to register pending.) Ends at the deploy gate. | -| At least one column has a `dual_writing` (or later) event recorded | **Encryption cutover** — backfill and schema rename. (Proxy users also: `stash db push` to register the renamed shape.) Requires the rollout to already be deployed. | -| `--complete-rollout` flag passed | **Complete rollout** — schema-add through drop, no deploy gate. Escape hatch for databases without a deployed application. Default-no confirm with a loud warning before generating. | +### How `DATABASE_URL` is resolved -The chosen template drives the agent's prompt body for the Claude Code, Codex, and AGENTS.md handoffs. The wizard handoff receives `--mode plan` on argv and reads the resolved step from `.cipherstash/context.json` (the `planStep` field). Every target produces a valid plan-mode artifact at `.cipherstash/plan.md`. +First hit wins: -The agent writes a machine-readable header `` at the top of the plan. `step` is `"rollout" | "cutover" | "complete"`; each column entry carries `path: "new" | "migrate"`. `stash impl` parses this header to render a confirmation panel and to enforce the deploy gate. +1. `--database-url ` flag +2. `DATABASE_URL` environment variable (including `.env*` files, loaded automatically) +3. `supabase status --output env` — only when `--supabase` is set or `supabase/config.toml` is detected +4. Interactive prompt — skipped under CI / non-TTY +5. Hard fail, naming the sources it tried -Ends with a default-yes prompt: *"Continue to `stash impl` now?"* Yes auto-launches `stash impl`. +`stash.config.ts` is **not** a separate tier: the scaffolded config calls this same resolver, and the `--database-url` flag still wins. The one exception is a hand-edited config assigning a literal `databaseUrl` string — that bypasses the resolver entirely and beats both flag and env. -To re-plan, delete `.cipherstash/plan.md` first — `stash plan` will warn (non-blocking) if a plan already exists, since the agent will be told to revise it rather than start fresh. +The resolved URL is returned in memory only. It is never written to disk or into `process.env`. -#### Why the rollout/cutover split +## Configuration -There is no atomic way to replace a populated plaintext column with an encrypted one without corrupting data. The rollout phase deploys the *capability* to write encrypted values (the encrypted twin column and the application-side dual-write code). The cutover phase deploys the *transition* (backfill historical rows, then rename swap so reads decrypt). Backfill is only safe once dual-writes are running in production, because any row written during the backfill window must be picked up by both columns — otherwise it lands in plaintext only and creates silent migration drift. The split makes that pre-condition explicit. +`stash.config.ts` in the project root: -### `impl` — Execute the plan +```typescript +import { defineConfig } from 'stash' -```bash -stash impl -stash impl --continue-without-plan -stash impl --target claude-code # non-interactive — skip the agent picker +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, + client: './src/encryption/index.ts', +}) ``` -`impl` is the **execute** save-point. Pre-flights `.cipherstash/context.json`. Behaviour branches on disk state: - -| State | Behaviour | -|-------|-----------| -| Plan exists, TTY, no `--target` | Parses the summary block. Enforces the deploy gate (see below). Renders a confirm panel describing the plan scope. Default-yes confirm, then the agent-target picker. | -| Plan exists, TTY, `--target X` | Same confirm panel, but skips the picker and runs handoff `X`. | -| Plan exists, non-TTY, `--target X` | Logs the plan path, skips both the confirm and the picker, runs handoff `X`. | -| Plan exists, non-TTY, no `--target` | Logs the plan path, prints a "No agent selected — pass `--target`" hint, and exits 0 without performing the handoff. The deploy-gate check still runs first. | -| No plan, TTY | Interactive `p.select`: "Draft a plan first (recommended)" / "Continue without a plan" / cancel. "Draft" delegates to `stash plan`. "Continue" goes through a security confirm (default-no) before implementing. | -| No plan, `--continue-without-plan` | Skips the picker, runs the security confirm (still default-no), then implements. | -| No plan, non-TTY, no flag | Errors out with "Run `stash plan` first, or pass `--continue-without-plan` to skip planning." Forces explicit intent in CI. | +| Option | Required | Default | Purpose | +|---|---|---|---| +| `databaseUrl` | yes | — | PostgreSQL connection string | +| `client` | no | `./src/encryption/index.ts` | Encryption client, loaded by `db push`, `db validate`, `schema build`, `encrypt *` | -`--target ` skips the interactive agent-target picker. **Required when invoking `impl` from a non-TTY context** (CI, pipes, an agent's Bash tool); without it, `impl` exits cleanly with a hint rather than hanging on `/dev/tty`. In a TTY, `--target` is optional. +Resolved by walking up from `process.cwd()`, like `tsconfig.json`. `stash init` scaffolds it; `stash eql install` offers to. -Once the user clears the gate, `impl` dispatches to a handoff target (Claude Code, Codex, AGENTS.md for Cursor/Windsurf/Cline, or `@cipherstash/wizard`) and the agent executes the plan: schema edits, migrations, `stash db push`, `stash encrypt {backfill,cutover,drop}` as appropriate. +## Setup lifecycle -#### Deploy-gate enforcement +Four explicit save-points. Each runs standalone; chain prompts make first-time setup one flow. -For plans with `step: "cutover"`, `impl` queries `cs_migrations` for every column listed in the plan-summary block and verifies that each one has a `dual_writing` (or later) event recorded. If any are missing, `impl` refuses to proceed and points the user at re-running `stash plan` after their rollout PR is deployed. The error names the specific columns that are not yet recorded. +| Command | Owns | Ends with | +|---|---|---| +| `stash init` | Auth, database, proxy choice, encryption client, deps, EQL install, `.cipherstash/context.json` | Default-yes prompt → chains to `stash plan` | +| `stash plan` | Drafts `.cipherstash/plan.md` via agent handoff. State-driven: auto-detects rollout vs. cutover. | Default-yes prompt → chains to `stash impl` | +| `stash impl` | Executes the plan via agent handoff. Enforces the deploy gate. | Deploy-gate banner (rollout) or "verify state" | +| `stash status` | The rollout quest log — per-column "where am I", runs in ms | — | -This is the safety net for the case where someone runs cutover work locally before the dual-write code is actually live in production. The encrypt commands themselves also gate on the same event before doing anything destructive, but `impl` checks early so the confirm prompt never appears for an unsafe plan. +### `init` — scaffold -#### Outro +Seven mechanical steps, no agent handoff. It prompts only when it can't pick a sensible default. -After a successful handoff: +1. **Authenticate** — silent when a valid token exists. +2. **Resolve database** — per the resolution order above; verifies the connection. +3. **Resolve proxy choice** — CipherStash Proxy or direct SDK access (the default). Stored as `usesProxy` in `context.json`; this is what decides whether `db push` is part of your flow at all. Set by `--proxy` / `--no-proxy`; non-TTY without a flag defaults to SDK. +4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there. +5. **Install dependencies** — one combined prompt for `@cipherstash/stack` and `stash`. Skipped when both are present. +6. **Install EQL** — runs `eql install` against the resolved database. Skipped when EQL is already installed. +7. **Gather context** — detects available coding agents and writes `.cipherstash/context.json`. -- **`step: "rollout"`** — prints a deploy-gate banner explaining that encrypted values are not yet flowing because the dual-write code is not deployed, with the next-step sequence (deploy → `stash status` → `stash plan`). -- **`step: "cutover"` or `step: "complete"`** — prints a generic "verify state" outro pointing at `stash status`. -- **No plan / no summary** — same generic outro. +Flags: `--supabase`, `--drizzle`, `--prisma-next`, `--proxy` / `--no-proxy`, `--region `. -`--continue-without-plan` exists to support scripts and one-off implementations where planning isn't needed. It is **not** a way to bypass safety — the security confirm still fires when interactive, and the cutover-step deploy-gate check applies regardless. +| Generated file | Purpose | +|---|---| +| `./src/encryption/index.ts` | Placeholder encryption client — declare encrypted columns here, or let `plan`/`impl` do it | +| `.cipherstash/context.json` | Detected facts: integration, package manager, schemas, env key names, agents, `usesProxy`. CLI-owned; never hand-edit | +| `stash.config.ts` | Scaffolded if missing | -### `status` — The encryption-rollout quest log +### `plan` — draft for review ```bash -stash status -stash status --quest # force the fancy output anywhere -stash status --plain # force the plain-text fallback anywhere -stash status --json # structured output for scripts +stash plan +stash plan --complete-rollout +stash plan --target claude-code ``` -`status` is the **map**. Reads `.cipherstash/context.json` (was init run?), `.cipherstash/migrations.json` (which columns are tracked?), and — best-effort — `cs_migrations` plus `eql_v2_configuration` for live per-column state. DB connectivity is optional; when missing, the command falls back to a manifest-only view and surfaces a footer note. - -Renders one **quest** per tracked column. Each quest carries: - -- A title (`Encrypt users.email` for migrate columns; `Add encrypted column orders.note` for new columns). -- A progress bar and an `N/M objectives` count. -- A list of objectives with `✓` for done, `▸` for the active "you are here" objective, and `🔒` for locked. -- A one-line "Next move" hint naming the concrete CLI invocation when relevant (`stash encrypt backfill --table users --column email`, etc.). +Pre-flights `.cipherstash/context.json` (errors with "Run `stash init` first" if missing), then hands off to a coding agent: Claude Code, Codex, AGENTS.md (Cursor/Windsurf/Cline), or the CipherStash Agent (`@cipherstash/wizard`). -Quests separate into **active** (something to do next) and **completed** (🏆 line per column). +`plan` is **state-driven**. It reads `.cipherstash/migrations.json` and `cs_migrations`: -Output mode: +| Detected state | Plan written | +|---|---| +| No `dual_writing` event recorded | **Encryption rollout** — schema-add + dual-write code. Ends at the deploy gate. | +| A column has `dual_writing` or later | **Encryption cutover** — backfill + schema rename. Requires the rollout to be deployed. | +| `--complete-rollout` passed | **Complete rollout** — schema-add through drop, no deploy gate. Default-no confirm with a loud warning. | -- **TTY by default** — the quest-log shape with emoji and progress bars. -- **Non-TTY by default** — a plain-text fallback with the same content (no emoji, bracketed status markers `[x]` / `[>]` / `[ ]`). Designed for CI logs, pipes, and agents reading the output. -- **`--quest`** forces the fancy shape anywhere; **`--plain`** forces the plain shape anywhere; **`--json`** emits a structured JSON document. +The agent writes a machine-readable header into the plan: -Use the JSON form for scripts; it has a stable shape (`active`, `completed`, per-quest `objectives`, `progress`, `nextMove`) that does not break without a major version bump. +``` + +``` -Run `status` after every transition during a rollout. It is the canonical "where am I?" surface; agents working through the rollout should re-read it as they go rather than tracking state mentally. +Each column carries `path: "new" | "migrate"`. `stash impl` parses this to render its confirmation panel and enforce the deploy gate. -For the deeper, raw views that touch only the database, use `stash eql status` (EQL installation state) and `stash encrypt status` (per-column migration phase, EQL state, backfill progress with drift detection). +To re-plan, delete `.cipherstash/plan.md` first — otherwise the agent is told to revise it rather than start fresh. `--complete-rollout` is the escape hatch for databases with no deployed application (local dev, sandboxes, test DBs); it's only safe when nothing in production writes to that database. -### `auth login` — Authenticate with CipherStash +### `impl` — execute ```bash -stash auth login +stash impl +stash impl --target claude-code +stash impl --continue-without-plan ``` -Opens a browser-based device code flow and saves a token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running. +Behaviour depends on disk state. With a plan: parses the summary, enforces the deploy gate, confirms, then hands off. Without a plan in a TTY: offers "draft a plan first (recommended)" / "continue without a plan" (behind a default-no security confirm). Without a plan in a non-TTY: errors unless `--continue-without-plan` is passed, forcing explicit intent in CI. -### `eql install` — Configure the database and install EQL extensions +`--continue-without-plan` skips *planning*, not safety. The security confirm still fires interactively, and the deploy gate applies regardless. -```bash -stash eql install -stash eql install --supabase -stash eql install --supabase --migration -stash eql install --supabase --direct -stash eql install --drizzle -stash eql install --force -``` +**Deploy-gate enforcement.** For a `cutover` plan, `impl` checks `cs_migrations` for a `dual_writing` (or later) event on every column in the plan summary. If any is missing it refuses, naming the columns. This catches the case where someone runs cutover work locally before the dual-write code is actually live in production. + +After a successful `rollout` handoff, `impl` prints a deploy-gate banner: encrypted values are not flowing yet. After `cutover` or `complete`, it points at `stash status`. -`stash db install` is a deprecated alias for this command — it still works but prints a deprecation warning. Always use `eql install` in new instructions. +### `status` — the rollout quest log -`stash init` runs `eql install` automatically as part of its EQL install phase. Run `eql install` directly when you skipped init, when you need flags init doesn't expose (`--migration`, `--migrations-dir`, `--exclude-operator-family`), or when re-installing/upgrading EQL on its own. +```bash +stash status +stash status --json # stable shape, for scripts and agents +stash status --plain # force plain text +stash status --quest # force the fancy output +``` -`eql install` is the single command that gets a project from zero to installed EQL: +Reads `context.json`, `migrations.json`, and — best-effort — `cs_migrations` plus live EQL state. Database connectivity is optional; without it you get a manifest-only view and a footer note. -1. Scaffolds `stash.config.ts` if missing (auto-detects an existing client file at common locations, otherwise prompts). -2. Loads the config. -3. **Safety net:** scaffolds the encryption client file at `config.client` if it doesn't exist (no-op when present). Lets users who skip `init` still end up with a working client file. -4. Detects Supabase (`DATABASE_URL` host) and Drizzle (lockfile / `drizzle-orm` dep) automatically. -5. For Drizzle: generates a Drizzle migration containing the EQL SQL (`drizzle-kit generate --custom --name=...`). -6. For Supabase non-Drizzle: prompts between writing a Supabase migration file and direct install. Pre-selects migration when `supabase/migrations/` exists. -7. Otherwise: installs EQL directly into the database. +Renders one quest per tracked column: a title, a progress bar, objectives (`✓` done, `▸` active, `🔒` locked), and a one-line "Next move" naming the concrete command. Quests split into **active** and **completed**. Defaults to the quest shape in a TTY, plain text elsewhere. -**Flags:** +**Re-read it after every transition** rather than tracking rollout state mentally. For raw database-only views, use `stash eql status` and `stash encrypt status`. -| Flag | Description | -|------|-------------| -| `--force` | Reinstall even if EQL is already installed | -| `--dry-run` | Show what would happen without making changes | -| `--supabase` | Supabase-compatible install (no operator families + grants `anon`, `authenticated`, `service_role`) | -| `--exclude-operator-family` | Skip operator family creation (useful for non-superuser roles) | -| `--drizzle` | Generate a Drizzle migration instead of direct install | -| `--latest` | Fetch latest EQL from GitHub instead of using the bundled version | -| `--name ` | Migration name when using `--drizzle` (default: `install-eql`) | -| `--out ` | Drizzle output directory when using `--drizzle` (default: `drizzle`) | -| `--migration` | Write the EQL SQL into a Supabase migration file (requires `--supabase`) | -| `--direct` | Run the EQL SQL directly against the database (requires `--supabase`; mutually exclusive with `--migration`) | -| `--migrations-dir ` | Override the Supabase migrations directory (requires `--supabase`; default: `supabase/migrations`) | +## Rolling encryption out to production -`--migration`, `--direct`, and `--migrations-dir` only make sense in the Supabase flow and require `--supabase` to be passed explicitly. They never auto-enable `--supabase`. +Two paths to a fully-encrypted column: -#### `eql install --drizzle` +- **New encrypted column** — declared encrypted from the start. Single deploy. Run `plan` → `impl` straight through. +- **Existing column with live data** — two passes around a hard production-deploy gate: `plan` → `impl` → **you deploy the rollout PR** → `status` → `plan` → `impl`. -When `--drizzle` is passed, the CLI: -1. Runs `drizzle-kit generate --custom --name=` to scaffold an empty migration. -2. Loads the bundled EQL install SQL (or downloads from GitHub with `--latest`). -3. Writes the SQL into the generated migration file. +The split is invisible — keep running `plan` and `impl`; the CLI reads `cs_migrations` and knows where you are. -You then run `npx drizzle-kit migrate` to apply it. Requires `drizzle-kit` as a dev dependency. +### Why the split exists -#### `eql install --supabase --migration` +There is no atomic way to replace a populated plaintext column with an encrypted one without corrupting data. The rollout phase deploys the *capability* to write encrypted values (the twin column and the dual-write code). The cutover phase deploys the *transition* (backfill historical rows, then rename-swap so reads decrypt). -Writes the EQL SQL to `supabase/migrations/00000000000000_cipherstash_eql.sql`. The all-zero timestamp ensures this migration runs before any user migrations that reference `eql_v2_encrypted`. Run `supabase db reset` (local) or `supabase migration up` (remote) to apply it. +Backfill is only safe once dual-writes are running in production. Any row written *during* the backfill window must land in both columns — otherwise it stays plaintext-only and creates silent migration drift. The gate makes that precondition explicit. -Direct-push installs (`--supabase --direct`) do **not** survive `supabase db reset` — the reset drops the database and reruns only files in `supabase/migrations/`. Use `--migration` for projects that use `supabase db reset`. +## Command reference -### `eql upgrade` — Upgrade EQL extensions +Flags below are the decision-relevant ones. Run `stash --help` for the complete, version-accurate list. -```bash -stash eql upgrade -stash eql upgrade --dry-run -stash eql upgrade --supabase -stash eql upgrade --latest -``` +### Setup & workflow -**Flags:** +| Command | Purpose | +|---|---| +| `init` | Scaffold a project (above) | +| `plan` | Draft `.cipherstash/plan.md` (above) | +| `impl` | Execute the plan (above) | +| `status` | Rollout quest log (above) | +| `manifest [--json]` | Print the structured, versioned command surface | +| `doctor` | Diagnose install problems (native binaries, runtime). Runs before the CLI body loads, so it works when the native binary is broken. | +| `wizard` | AI-guided encryption setup — thin wrapper over `@cipherstash/wizard` | -| Flag | Description | -|------|-------------| -| `--dry-run` | Show what would happen without making changes | -| `--supabase` | Use Supabase-compatible upgrade | -| `--exclude-operator-family` | Skip operator family creation | -| `--latest` | Fetch latest EQL from GitHub instead of bundled | +### Auth -The EQL install SQL is idempotent and safe to re-run. The command checks the current version, re-runs the install SQL, then reports the new version. If EQL is not installed, it suggests running `eql install` instead. +`auth login`, `auth regions` — see [Authentication](#authentication). -### `db validate` — Validate encryption schema +### EQL ```bash -stash db validate -stash db validate --supabase -stash db validate --exclude-operator-family +stash eql install +stash eql upgrade +stash eql status ``` -**Flags:** - -| Flag | Description | -|------|-------------| -| `--supabase` | Check for Supabase-specific issues | -| `--exclude-operator-family` | Check for issues when operator families are excluded | +> `stash db install`, `db upgrade`, and `db status` still work but print a deprecation warning and forward to `eql `. Use the `eql` spelling. -**Validation rules:** +#### `eql install` -| Rule | Severity | Description | -|------|----------|-------------| -| `freeTextSearch` on non-string column | Warning | Free-text search only works with string data | -| `orderAndRange` without operator families | Warning | ORDER BY won't work without operator families | -| No indexes on encrypted column | Info | Column is encrypted but not searchable | -| `searchableJson` without `json` data type | Error | searchableJson requires `dataType("json")` | +Gets a project from zero to installed EQL. It loads an existing `stash.config.ts` (or offers to scaffold one), scaffolds the encryption client if missing, then auto-detects the install path: **Drizzle** generates a migration via `drizzle-kit generate --custom` (apply it with `npx drizzle-kit migrate`); **Supabase without Drizzle** prompts between a migration file and a direct install, pre-selecting migration when `supabase/migrations/` exists; otherwise it installs directly. -Validation also runs automatically before `db push` — issues are logged as warnings but don't block the push. - -### `db push` — Register the encryption schema with EQL +| Flag | Description | +|---|---| +| `--force` | Reinstall even if EQL is present | +| `--dry-run` | Show what would happen | +| `--supabase` | Supabase-compatible install (no operator families; grants `anon`, `authenticated`, `service_role`) | +| `--drizzle` | Generate a Drizzle migration (`--name`, `--out` tune it) | +| `--migration` / `--direct` | Supabase: write a migration file, or run SQL directly | +| `--migrations-dir ` | Supabase migrations directory (default `supabase/migrations`) | +| `--exclude-operator-family` | Skip operator families (non-superuser roles) | +| `--eql-version <2\|3>` | EQL generation. **Default `2`.** `3` is the native `eql_v3.*` domain schema. | +| `--latest` | Fetch latest EQL from GitHub instead of the bundled copy (**v2 only**) | +| `--database-url ` | One-shot install (see below) | -Synchronises the CipherStash configuration in `eql_v2_configuration` with what your encryption client declares. +`--migration`, `--direct`, and `--migrations-dir` require an explicit `--supabase`; they never auto-enable it. -**Required for CipherStash Proxy users** — Proxy needs to know which columns to encrypt/decrypt. +**EQL v3 installs via the direct path only.** Passing `--eql-version 3` with an explicit `--drizzle`, `--migration`, `--migrations-dir`, or `--latest` is an error. When Drizzle or a Supabase migrations directory is merely *auto-detected*, v3 falls back to a direct install and prints a notice. -**Not needed for SDK users** — Drizzle, Supabase, and plain PostgreSQL SDK users have their encryption config in application code. The database does not need a copy. See the "Known gap" note below. +**`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx stash eql install --database-url postgres://...` run in a bare project with no CipherStash dependencies. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database. -```bash -stash db push -stash db push --dry-run -``` +**`eql install --supabase --migration`** writes `supabase/migrations/00000000000000_cipherstash_eql.sql`. The all-zero timestamp guarantees it runs before any user migration referencing `eql_v2_encrypted`. Apply with `supabase db reset` (local) or `supabase migration up` (remote). -**Flags:** +Direct installs (`--supabase --direct`) do **not** survive `supabase db reset` — the reset drops the database and replays only files in `supabase/migrations/`. Use `--migration` if you reset. -| Flag | Description | -|------|-------------| -| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. | +#### `eql upgrade` -When pushing, the CLI: +The install SQL is idempotent. `upgrade` checks the current version, re-runs it, and reports the new one. If EQL isn't installed it points you at `eql install`. Same `--supabase`, `--exclude-operator-family`, `--eql-version`, `--latest`, `--dry-run`, `--database-url` flags. -1. Loads the encryption client from the path in `stash.config.ts`. -2. Runs schema validation (warns but doesn't block). -3. Transforms SDK data types to EQL-compatible `cast_as` values (see table below). -4. Connects to Postgres and decides where to write based on existing state: - - **No active EQL config exists** (first push) → writes directly to `active`. Encryption is live immediately. No further step required. - - **Active config already exists** → writes the new config as `pending`, replacing any prior pending. The active config keeps serving until you finalise the change with one of the activation commands below. +#### `eql status` -**Activation after a `pending` push:** +Whether EQL is installed and at which version; database permission status; whether an active encrypt config exists (relevant only to Proxy users). -| Situation | Command | -|-----------|---------| -| Adding a brand-new encrypted column (no rename) | `stash db activate` | -| Cutting over from a `_encrypted` twin (path 3 lifecycle) | `stash encrypt cutover --table T --column C` | +### Database -> **Known gap:** `stash encrypt cutover` currently requires a pending EQL configuration (satisfied by `stash db push`). SDK-only users running the migrate-existing-column flow will encounter this precondition. Work to decouple `encrypt cutover` from EQL config for SDK-only users (using direct SQL rename instead) is tracked as follow-up work to [issue #447](https://github.com/cipherstash/stack/issues/447) and will be addressed in a future release. +#### `db validate` — validate the encryption schema -**SDK to EQL type mapping:** +| Rule | Severity | +|---|---| +| `freeTextSearch` on a non-string column | Warning | +| `orderAndRange` without operator families | Warning | +| No indexes on an encrypted column | Info | +| `searchableJson` without `dataType("json")` | Error | -| SDK type (`dataType()`) | EQL `cast_as` | -|-------------------------|---------------| -| `string` | `text` | -| `text` | `text` | -| `number` | `double` | -| `bigint` | `big_int` | -| `boolean` | `boolean` | -| `date` | `date` | -| `json` | `jsonb` | +Runs automatically before `db push`, where issues warn but don't block. Exits 1 on errors only. -### `db activate` — Promote pending → active without renaming +#### `db push` / `db activate` — EQL v2 + CipherStash Proxy only -Runs `eql_v2.migrate_config()` followed by `eql_v2.activate_config()` inside a single transaction, advancing any `pending` row to `active` (and marking the prior `active` as `inactive`). No physical column renames. +> **SDK users skip both.** Drizzle, Supabase, and plain-PostgreSQL SDK users keep their encryption config in application code; the database needs no copy. These commands exist for **CipherStash Proxy**, which reads `public.eql_v2_configuration` to know which columns to encrypt and decrypt. `stash init` records this as `usesProxy`. ```bash +stash db push [--dry-run] stash db activate ``` -Use after `stash db push` when the new config purely adds columns or changes index ops without renaming any column. For path 3 (existing populated column → encrypted), use `stash encrypt cutover` instead — it does the same activation plus the physical rename. - -Errors out with a clear message when there is no pending configuration to activate. - -### `eql status` — Show EQL installation status +`db push` loads the encryption client, validates it, maps SDK data types to EQL `cast_as` values, then writes to `eql_v2_configuration`: -```bash -stash eql status -``` +- **No active config** (first push) → writes `active` directly. Encryption is live immediately. +- **Active config exists** → writes `pending`, replacing any prior pending. The active config keeps serving reads until you finalise. -Reports: -- Whether EQL is installed and which version. -- Database permission status. -- Whether an active encrypt config exists in `eql_v2_configuration` (only relevant for CipherStash Proxy). +Finalising a `pending` push: -### `db test-connection` — Test database connectivity +| Situation | Command | +|---|---| +| Adding a new encrypted column, no rename | `stash db activate` | +| Cutting over from a `_encrypted` twin | `stash encrypt cutover --table T --column C` | -```bash -stash db test-connection -``` +`db activate` runs `eql_v2.migrate_config()` then `eql_v2.activate_config()` in one transaction, promoting `pending` → `active` and marking the prior active `inactive`. No physical rename. It errors when there is nothing pending. -Verifies the database URL in your config is valid and the database is reachable. Reports the database name, connected role, and PostgreSQL server version. Useful for debugging connection issues before running `eql install`. +**SDK to EQL type mapping** -### `db migrate` — Run pending encrypt config migrations +| `dataType()` | EQL `cast_as` | +|---|---| +| `string`, `text` | `text` | +| `number` | `double` | +| `bigint` | `big_int` | +| `boolean` | `boolean` | +| `date` | `date` | +| `timestamp` | `timestamp` | +| `json` | `jsonb` | -```bash -stash db migrate -``` +#### `db test-connection` -Not yet implemented — placeholder for future encrypt-config migration tooling. +Verifies the database URL is valid and reachable. Reports database name, connected role, and server version. -### `encrypt` — Drive the encryption-cutover work for a column +#### `db migrate` -The `encrypt` group is the cutover-step toolset: it runs the database-side work that takes an existing plaintext column the rest of the way to encrypted, after the encryption-rollout PR is deployed and dual-writes are live in production. The internal event log uses `schema-added → dual-writing → backfilling → backfilled → cut-over → dropped` as machine-readable phase names; the user-facing story is the rollout/cutover model documented in the `stash-encryption` skill. +Not implemented — prints a warning and exits. Placeholder for future encrypt-config migration tooling. -It drives the `@cipherstash/migrate` library, which records every transition in a `cipherstash.cs_migrations` table (installed by `stash eql install`) and reads the user's intent from `.cipherstash/migrations.json`. This section documents the CLI surface. +### Schema -The examples below show the bare `stash` form, which works after `stash init` adds the CLI as a project dev dep. See the "CLI Usage" section above for how to invoke it through your package manager before that. +#### `schema build` -#### `encrypt status` — Show per-column phase, EQL state, and backfill progress +Connects to the database, lets you select tables and columns, asks about searchable indexes, and generates a typed encryption client. Flags: `--supabase`, `--database-url`. -```bash -stash encrypt status -stash encrypt status --table users -``` +For AI-guided integration that edits your existing schema files in place, prefer `stash plan` → `stash impl`. -Reads three sources in parallel — the `migrations.json` manifest (intent), the live `eql_v2_configuration` row (EQL state), and the latest `cs_migrations` event per column (runtime state) — and renders a table per column with phase, indexes, progress, and any drift between intent and observed state. +### Encrypt -#### `encrypt plan` — Diff intent vs. observed state +The cutover-step toolset: the database-side work that takes an existing plaintext column the rest of the way, **after** the rollout PR is deployed and dual-writes are live. It drives `@cipherstash/migrate`, recording every transition in `cipherstash.cs_migrations` (installed by `eql install`) and reading intent from `.cipherstash/migrations.json`. Internal phase names: `schema-added → dual-writing → backfilling → backfilled → cut-over → dropped`. -```bash -stash encrypt plan -``` +#### `encrypt status` / `encrypt plan` -Like `status`, but explicitly lists what would change to reconcile observed state with `.cipherstash/migrations.json`. Read-only — does not mutate the DB or the manifest. +`status` renders per-column phase, indexes, backfill progress, and any drift between intent and observed state. `plan` lists what would change to reconcile them. Both are read-only. -#### `encrypt backfill` — Resumably encrypt plaintext into the encrypted column +#### `encrypt backfill` ```bash stash encrypt backfill --table users --column email stash encrypt backfill --table users --column email --chunk-size 5000 -stash encrypt backfill --table users --column email --confirm-dual-writes-deployed -stash encrypt backfill --table users --column email --force ``` -Chunked, resumable, idempotent backfill. Walks the table in keyset-pagination order, encrypts each chunk via `bulkEncryptModels` from `@cipherstash/stack`, and writes a single `UPDATE ... FROM (VALUES ...)` per chunk inside a transaction that also checkpoints to `cs_migrations`. SIGINT/SIGTERM finishes the current chunk and exits cleanly; re-running picks up from the last checkpoint. The ` IS NOT NULL AND _encrypted IS NULL` guard makes concurrent runners and re-runs safe — they converge. - -**Dual-write precondition.** Backfill requires the application to already be writing to both `` (plaintext) and `_encrypted` (ciphertext) on every insert/update — otherwise rows inserted *during* the backfill land in plaintext only and create silent migration drift. The first run on a column prompts the user (interactive) or accepts `--confirm-dual-writes-deployed` (non-interactive, with a loud warning), then records the `dual_writing` transition in `cs_migrations`. Subsequent runs / resumes don't need the prompt — the bookmark is persisted. +Chunked, resumable, idempotent. Walks the table in keyset-pagination order, encrypts each chunk via `bulkEncryptModels`, and writes one `UPDATE ... FROM (VALUES ...)` per chunk in a transaction that also checkpoints to `cs_migrations`. SIGINT/SIGTERM finishes the current chunk and exits cleanly; re-running resumes. The ` IS NOT NULL AND _encrypted IS NULL` guard makes concurrent runners and re-runs converge. -Flags: +**Dual-write precondition.** The application must already write both `` and `_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt. -- `--table ` / `--column ` — required. -- `--chunk-size ` — default 1000. Lower for lock contention, raise for wide rows. -- `--pk-column ` — override primary-key auto-detection. Required for composite PKs (pick one comparable column). -- `--encrypted-column ` — override `_encrypted` if your schema uses a non-standard target name. -- `--schema-column-key ` — override the key used to look up the column in the `EncryptedTable` schema; defaults to the encrypted column name. -- `--confirm-dual-writes-deployed` — non-interactive equivalent of saying yes to the dual-write prompt. Use in CI/scripts. -- `--force` — re-encrypt every plaintext row, including rows that already have a (potentially stale) ciphertext. Recovery path for drift caused by dual-writes that weren't actually deployed when an earlier backfill ran. Expensive but not destructive — re-encrypting a correctly-encrypted value just rewrites the same payload. Audit-trail-flagged via `details.force = true` in `cs_migrations`. +| Flag | Description | +|---|---| +| `--table` / `--column` | Required | +| `--chunk-size ` | Default 1000. Lower for lock contention, raise for wide rows. | +| `--pk-column ` | Override primary-key detection. Required for composite PKs. | +| `--encrypted-column ` | Override the `_encrypted` target name | +| `--schema-column-key ` | Override the schema lookup key | +| `--confirm-dual-writes-deployed` | Non-interactive equivalent of the prompt | +| `--force` | Re-encrypt every plaintext row, including ones with existing ciphertext. Recovery path for drift. Expensive, not destructive. Flagged in the audit trail. | -#### `encrypt cutover` — Rename swap encrypted → primary column AND promote pending → active +#### `encrypt cutover` ```bash stash encrypt cutover --table users --column email ``` -**Precondition:** the column must be in the `backfilled` phase per `cs_migrations`, AND a pending EQL configuration must exist (registered via `stash db push` against a schema where the column is declared under its final name without the `_encrypted` suffix). +**Preconditions:** the column is in the `backfilled` phase, **and** a pending EQL configuration exists. -In a single transaction, the command: +In one transaction it renames `` → `_plaintext` and `_encrypted` → ``, advances the pending config to `encrypting`, activates it, and appends a `cut_over` event. With a Proxy URL configured (`--proxy-url` or `CIPHERSTASH_PROXY_URL`) it then calls `eql_v2.reload_config()` so Proxy picks up the new shape. Reads of `` now decrypt transparently — no application read-path change. -1. Runs `eql_v2.rename_encrypted_columns()` to rename `` → `_plaintext` and `_encrypted` → ``. -2. Runs `eql_v2.migrate_config()` to advance the pending config to `encrypting`. -3. Runs `eql_v2.activate_config()` to promote it to `active` (and mark the prior active config as `inactive`). -4. Appends a `cut_over` event to `cs_migrations` for the column. +> **Known gap.** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. Decoupling this — under EQL v3 there is no configuration table at all — is tracked in [issue #585](https://github.com/cipherstash/stack/issues/585). -If a Proxy URL is configured (via `--proxy-url` or `CIPHERSTASH_PROXY_URL`), it then connects to the Proxy and calls `eql_v2.reload_config()` so Proxy picks up the new shape immediately. App reads of `` now return decrypted ciphertext transparently — no app code change required for reads. +Flags: `--table`, `--column`, `--proxy-url `, `--migrations-dir `. -#### `encrypt drop` — Generate a migration that removes the plaintext column +#### `encrypt drop` ```bash stash encrypt drop --table users --column email ``` -For columns in the `cut_over` phase. Detects the user's migration tooling (Drizzle today; Prisma + raw-SQL planned) and emits a migration file containing `ALTER TABLE DROP COLUMN _plaintext;`. Does not apply the migration — the user reviews and runs their normal migrate command. Records the `dropped` event only after a follow-up `encrypt status` confirms the column is gone from `information_schema.columns`. +For columns in the `cut_over` phase. Detects your migration tooling and emits a migration containing `ALTER TABLE
DROP COLUMN _plaintext;`. It does **not** apply it — review and run your own migrate command. The `dropped` event is recorded only after a later `encrypt status` confirms the column is gone. -### `schema build` — Generate an encryption client from your database +Flags: `--table`, `--column`, `--migrations-dir `. -```bash -stash schema build -stash schema build --supabase -``` - -Connects to your database, lets you select tables and columns to encrypt, asks about searchable indexes, and generates a typed encryption client file. Reads `databaseUrl` from `stash.config.ts`. +### Experimental -For AI-guided schema integration that edits your existing schema files in place, the recommended path is `stash plan` followed by `stash impl` — these add a planning save-point and can hand off to Claude Code, Codex, an AGENTS.md-driven editor, or the in-house `@cipherstash/wizard` package. `npx @cipherstash/wizard` standalone is still available for users who want to skip the plan checkpoint. - -### `env` — Print production env vars for deployment +#### `env` ```bash stash env -stash env --write ``` -Experimental. Prints the environment variables (`CS_*`) you need to deploy a CipherStash-backed app. With `--write`, writes them into a `.env.production` file. +**A stub — it does not work yet.** Gated behind `STASH_EXPERIMENTAL_ENV_CMD`, and even then the credential mint endpoint is not wired up, so it emits nothing. Intended to print the `CS_*` variables needed to deploy. Don't build on it. ## Programmatic API -### `defineConfig(config: StashConfig): StashConfig` - -Identity function that provides type-safe configuration for `stash.config.ts`. - -### `loadStashConfig(): Promise` - -Finds and loads `stash.config.ts` from the current directory or any parent. Validates with Zod. Applies defaults (e.g. `client` defaults to `'./src/encryption/index.ts'`). Exits with code 1 if config is missing or invalid. - -### `loadBundledEqlSql(options?): string` - -Load the bundled EQL install SQL as a string: - ```typescript -import { loadBundledEqlSql } from 'stash' - -const sql = loadBundledEqlSql() // standard -const sql = loadBundledEqlSql({ supabase: true }) // supabase variant -const sql = loadBundledEqlSql({ excludeOperatorFamily: true }) // no operator family +import { + defineConfig, loadStashConfig, resolveDatabaseUrl, + EQLInstaller, loadBundledEqlSql, downloadEqlSql, +} from 'stash' ``` -### `downloadEqlSql(excludeOperatorFamily?): Promise` - -Download the latest EQL install SQL from GitHub releases. +| Export | Signature | +|---|---| +| `defineConfig` | `(config: StashConfig) => StashConfig` — identity function for type-checking | +| `loadStashConfig` | `(resolverOptions?: ResolveDatabaseUrlOptions, knownConfigPath?: string) => Promise` — walks up for the config, validates with Zod, applies defaults, exits 1 if missing or invalid | +| `resolveDatabaseUrl` | `(opts?: ResolveDatabaseUrlOptions) => Promise` — the resolution chain documented above | +| `loadBundledEqlSql` | `(options?: { supabase?, excludeOperatorFamily?, eqlVersion?: 2 \| 3 }) => string` | +| `downloadEqlSql` | `(options?: { excludeOperatorFamily?, supabase? } \| boolean) => Promise` — latest EQL from GitHub releases | ### `EQLInstaller` ```typescript -import { EQLInstaller } from 'stash' - const installer = new EQLInstaller({ databaseUrl: 'postgresql://...' }) -``` -#### `installer.checkPermissions(): Promise` +await installer.checkPermissions() // PermissionCheckResult +await installer.isInstalled({ eqlVersion: 3 }) // boolean +await installer.getInstalledVersion() // string | 'unknown' | null +await installer.install({ supabase: true }) // executes in a transaction +``` -Checks that the database role has the required permissions to install EQL. +`isInstalled`, `getInstalledVersion`, and `install` all accept `eqlVersion?: 2 | 3` (default `2`), selecting the `eql_v2` or `eql_v3` schema. `install` also takes `excludeOperatorFamily`, `supabase`, and `latest` (v2 only). ```typescript type PermissionCheckResult = { - ok: boolean // true if all permissions are present - missing: string[] // list of missing permissions (empty if ok) + ok: boolean // all required permissions present + missing: string[] // what's absent + isSuperuser: boolean // drives the automatic no-operator-family fallback } ``` -Required permissions (one of): -- `SUPERUSER` role (sufficient for everything), OR -- `CREATE` privilege on database + `CREATE` privilege on `public` schema -- If `pgcrypto` is not installed: also needs `SUPERUSER` or `CREATEDB` - -#### `installer.isInstalled(): Promise` - -Returns `true` if the `eql_v2` schema exists in the database. - -#### `installer.getInstalledVersion(): Promise` - -Returns the installed EQL version string, `'unknown'` if schema exists but no version metadata, or `null` if not installed. - -#### `installer.install(options?): Promise` - -Executes the EQL install SQL in a transaction. - -```typescript -await installer.install({ - excludeOperatorFamily?: boolean // Skip operator family creation - supabase?: boolean // Use Supabase-compatible install + grant roles - latest?: boolean // Fetch latest from GitHub instead of bundled -}) -``` - -## Full programmatic example - -```typescript -import { EQLInstaller, loadStashConfig } from 'stash' - -const config = await loadStashConfig() -const installer = new EQLInstaller({ databaseUrl: config.databaseUrl }) - -// Check permissions first -const permissions = await installer.checkPermissions() -if (!permissions.ok) { - console.error('Missing permissions:', permissions.missing) - process.exit(1) -} - -// Install if not already present -if (await installer.isInstalled()) { - const version = await installer.getInstalledVersion() - console.log(`EQL already installed (version: ${version})`) -} else { - await installer.install() - console.log('EQL installed successfully') -} -``` +Required: `SUPERUSER`, **or** `CREATE` on the database *and* on the `public` schema. If `pgcrypto` is absent, also `SUPERUSER` or `CREATEDB`. ## Requirements - Node.js >= 22 -- PostgreSQL database with sufficient permissions (see `checkPermissions()`) -- A `stash.config.ts` file with a valid `databaseUrl` (or run `stash init` / `stash eql install` to scaffold it) -- Peer dependency: `@cipherstash/stack` >= 0.6.0 +- PostgreSQL with sufficient permissions (see `checkPermissions()`) +- `stash.config.ts` with a valid `databaseUrl` — or run `stash init` / `stash eql install` to scaffold it +- Optional peer dependency: `@cipherstash/stack` >= 0.6.0 (required for the commands that load your encryption client) ## Common issues -### Permission errors during install - -The database role needs `CREATE` privileges on the database and public schema, or `SUPERUSER`. Run `checkPermissions()` or check the CLI output for details on what's missing. - -### Config not found - -`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. The fastest fix is `stash init`, which scaffolds the config (and authenticates, installs deps, installs EQL, and writes `.cipherstash/context.json` in the same run). For a CLI-only setup, `stash eql install` also scaffolds the config. - -### Supabase environments - -Always use `--supabase` (or `supabase: true` programmatically) when targeting Supabase. This uses a compatible install script and grants permissions to `anon`, `authenticated`, and `service_role` roles. +**Permission errors during install.** The role needs `CREATE` on the database and the `public` schema, or `SUPERUSER`. Check the CLI output for exactly what's missing. -### Operator families and ORDER BY +**Config not found.** `stash.config.ts` must be in the project root or a parent, and must `export default defineConfig(...)`. Fastest fix: `stash init`. For a CLI-only setup, `stash eql install` scaffolds it too. -When EQL is installed with `--supabase` or `--exclude-operator-family`, PostgreSQL operator families are not created. This means `ORDER BY` on encrypted columns is **not currently supported** — regardless of the client or ORM used (Drizzle, Supabase JS SDK, raw SQL, etc.). +**Supabase.** Always pass `--supabase` (or `supabase: true`). It selects a compatible install script and grants `anon`, `authenticated`, and `service_role`. -Sort application-side after decrypting the results as a workaround. +**`ORDER BY` on encrypted columns doesn't work.** When EQL is installed with `--supabase` or `--exclude-operator-family`, PostgreSQL operator families aren't created, so `ORDER BY` on an encrypted column is unsupported — regardless of client or ORM. Sort application-side after decrypting. Operator-family support for Supabase is in development. -Operator family support for Supabase is being developed with the Supabase and CipherStash teams and will be available in a future release. This limitation applies to any database environment where operator families are not installed. +**The native binary won't load.** Run `stash doctor`. ## Related skills -- **`@cipherstash/wizard`** — AI-guided encryption setup. Reads your codebase, asks which columns to encrypt, edits your schema and call sites in place. Run with `npx @cipherstash/wizard`. Separate package from this CLI. -- **`stash-encryption`** — Defines encrypted schemas and uses `Encryption()` / `encryptModel` / `decryptModel` at runtime via `@cipherstash/stack`. -- **`stash-drizzle`** / **`stash-supabase`** — Drizzle and Supabase integrations. +- **`stash-encryption`** — the encryption API, schema definition, and the canonical rollout/cutover model. +- **`stash-drizzle`** / **`stash-supabase`** / **`stash-dynamodb`** — integration-specific patterns. +- **`@cipherstash/wizard`** — AI-guided setup as a standalone package (`npx @cipherstash/wizard`), also reachable as `stash wizard`. From aad916415bac75cd72a2e71be5bd241cae66e40f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 16:34:43 +1000 Subject: [PATCH 2/3] fix(docs): correct the post-cutover read path and unban .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #594. **The read path after `encrypt cutover` is not automatic.** The skill claimed "Reads of `` now decrypt transparently — no application read-path change." That is only true for CipherStash Proxy, which decrypts on the wire. For SDK users -- the majority -- `` holds ciphertext after the rename swap, and read paths must go through the encryption client or they hand raw EQL payloads to end users. The CLI's own guidance already says so (`init/lib/setup-prompt.ts:296`): "Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers." The claim was inherited from the old skill, and it had propagated to three places, all corrected here: - `encrypt cutover` now carries an explicit callout naming `decryptModel` / `encryptedSupabase` / `bulkDecryptModels`, with Proxy as the stated exception. - The `stash plan` state table described a cutover as "backfill + schema rename"; it is backfill, rename, read-path switch, drop. - "Why the split exists" said the cutover ends with a "rename-swap so reads decrypt", which begs the question. **`.env.example` is no longer caught by the secrets read-ban.** The doctrine forbade reading "any `.env*` file" in the same breath as instructing agents to add placeholder keys to `.env.example`. Narrowed to value-bearing env files, with `.env.example` called out as the exception. Mirrored in the skill. Verified: every command and flag still resolves against `stash manifest --json`; 406 unit tests pass. --- .../src/commands/init/doctrine/AGENTS-doctrine.md | 2 +- skills/stash-cli/SKILL.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md index eee2ddc24..a7e408a57 100644 --- a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -15,7 +15,7 @@ This document is the **durable rule book** for any agent working on this codebas 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. Never read, `cat`, `grep`, or echo `~/.cipherstash/secretkey.json` (the development key), `~/.cipherstash/auth.json` (OAuth token and JWTs), anything under `~/.cipherstash/workspaces/`, or any `.env*` file. The CLI reads these itself — no command needs you to open them. If a command fails on authentication, re-run `stash auth login` rather than inspecting the profile. +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. Never read, `cat`, `grep`, or echo `~/.cipherstash/secretkey.json` (the development key), `~/.cipherstash/auth.json` (OAuth token and JWTs), anything under `~/.cipherstash/workspaces/`, or a value-bearing env file (`.env`, `.env.local`, `.env.production`, …). `.env.example` is the exception — it holds placeholders, not values, and you are expected to edit it. The CLI reads the credentials itself; no command needs you to open them. If a command fails on authentication, re-run `stash auth login` rather than inspecting the profile. 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). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash eql upgrade`, not a hand-edit). diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 8eff9506c..ee38879f6 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -95,7 +95,9 @@ The CLI holds your credentials and reads them itself. No command needs you to op - `~/.cipherstash/secretkey.json` — the development key - `~/.cipherstash/auth.json` — OAuth token and JWTs - anything under `~/.cipherstash/workspaces/` -- `.env`, `.env.local`, `.env.production`, or any credentials file +- value-bearing env files — `.env`, `.env.local`, `.env.production`, … — and any credentials file + +`.env.example` is the exception: it holds placeholders, not values, and you are expected to edit it. Referring to env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) in code and docs is fine. Their *values* are not. New keys go into `.env.example` as placeholders; ask the user to fill in the real value locally. @@ -204,7 +206,7 @@ Pre-flights `.cipherstash/context.json` (errors with "Run `stash init` first" if | Detected state | Plan written | |---|---| | No `dual_writing` event recorded | **Encryption rollout** — schema-add + dual-write code. Ends at the deploy gate. | -| A column has `dual_writing` or later | **Encryption cutover** — backfill + schema rename. Requires the rollout to be deployed. | +| A column has `dual_writing` or later | **Encryption cutover** — backfill, schema rename, read-path switch, drop. Requires the rollout to be deployed. | | `--complete-rollout` passed | **Complete rollout** — schema-add through drop, no deploy gate. Default-no confirm with a loud warning. | The agent writes a machine-readable header into the plan: @@ -259,7 +261,7 @@ The split is invisible — keep running `plan` and `impl`; the CLI reads `cs_mig ### Why the split exists -There is no atomic way to replace a populated plaintext column with an encrypted one without corrupting data. The rollout phase deploys the *capability* to write encrypted values (the twin column and the dual-write code). The cutover phase deploys the *transition* (backfill historical rows, then rename-swap so reads decrypt). +There is no atomic way to replace a populated plaintext column with an encrypted one without corrupting data. The rollout phase deploys the *capability* to write encrypted values (the twin column and the dual-write code). The cutover phase deploys the *transition*: backfill historical rows, rename-swap the columns, switch the application read path through the encryption client, then drop the plaintext column. Backfill is only safe once dual-writes are running in production. Any row written *during* the backfill window must land in both columns — otherwise it stays plaintext-only and creates silent migration drift. The gate makes that precondition explicit. @@ -429,7 +431,9 @@ stash encrypt cutover --table users --column email **Preconditions:** the column is in the `backfilled` phase, **and** a pending EQL configuration exists. -In one transaction it renames `` → `_plaintext` and `_encrypted` → ``, advances the pending config to `encrypting`, activates it, and appends a `cut_over` event. With a Proxy URL configured (`--proxy-url` or `CIPHERSTASH_PROXY_URL`) it then calls `eql_v2.reload_config()` so Proxy picks up the new shape. Reads of `` now decrypt transparently — no application read-path change. +In one transaction it renames `` → `_plaintext` and `_encrypted` → ``, advances the pending config to `encrypting`, activates it, and appends a `cut_over` event. With a Proxy URL configured (`--proxy-url` or `CIPHERSTASH_PROXY_URL`) it then calls `eql_v2.reload_config()` so Proxy picks up the new shape. + +> **After cutover, `` holds ciphertext — the read path is not automatic.** Wire reads through the encryption client (`decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, otherwise `decrypt` / `bulkDecryptModels`) before returning values to callers. Skip this and your read paths hand raw EQL payloads to end users. The integration skill has the exact API. **CipherStash Proxy is the one exception** — it decrypts on the wire, so Proxy users need no application change. The cutover plan written by `stash plan` includes this read-path switch as an explicit step. > **Known gap.** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. Decoupling this — under EQL v3 there is no configuration table at all — is tracked in [issue #585](https://github.com/cipherstash/stack/issues/585). From 9c673bb034a732a4b111b33302efc914fee38bca Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 16:43:12 +1000 Subject: [PATCH 3/3] fix(wizard): stop the agent guard from blocking .env.example Completes the review fix on #594. The doctrine now says `.env.example` is exempt from the secrets read-ban; the wizard's runtime guard still blocked it. `SENSITIVE_FILE_PATTERNS` matched `/\.env($|\.)/`, which tests true against `.env.example`. Because `wizardCanUseTool` applies that guard to `Edit` and `Write` as well as `Read`, the agent could not create or edit the one file the doctrine explicitly instructs it to write. Committed env templates carry placeholder key names, not values. Narrow the rule with an anchored negative lookahead so `.env.example`, `.env.sample` and `.env.template` are reachable, while `.env`, `.env.local`, `.env.production` and near-miss names that do carry values (`.env.example.local`, `.env.example.bak`) stay blocked. `auth.json`, `secretkey.json` and credential files are untouched. Bash remains deliberately stricter: no env file, template or not, is reachable from a shell. `cat` isn't in the bash allowlist anyway, and Read/Write is the sanctioned path -- so there's no reason to widen the riskiest surface. Noted inline. Adds five cases covering the exemption, the near-miss names, and the Bash asymmetry. `pnpm --filter @cipherstash/wizard test` passes (150). --- .changeset/wizard-allow-env-templates.md | 18 ++++++++++ .../wizard/src/__tests__/interface.test.ts | 36 +++++++++++++++++++ packages/wizard/src/agent/interface.ts | 16 +++++++-- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 .changeset/wizard-allow-env-templates.md diff --git a/.changeset/wizard-allow-env-templates.md b/.changeset/wizard-allow-env-templates.md new file mode 100644 index 000000000..7c5e48b4e --- /dev/null +++ b/.changeset/wizard-allow-env-templates.md @@ -0,0 +1,18 @@ +--- +"@cipherstash/wizard": patch +--- + +Stop the agent guard from blocking `.env.example`. + +`SENSITIVE_FILE_PATTERNS` matched `/\.env($|\.)/`, which tests true against +`.env.example`. Because the guard covers `Edit` and `Write` as well as `Read`, +the wizard's agent was blocked from creating or editing the very file the +CipherStash doctrine tells it to write ("New env keys go in `.env.example` with +placeholders"). Committed env templates carry placeholder key names, not values. + +`.env.example`, `.env.sample` and `.env.template` are now readable and writable. +Everything else is unchanged: `.env`, `.env.local`, `.env.production`, and +value-bearing files that merely start with a template name +(`.env.example.local`, `.env.example.bak`) stay blocked, as do `auth.json`, +`secretkey.json` and credential files. Bash access to any env file — including +the templates — remains blocked; `Read`/`Write` is the sanctioned path. diff --git a/packages/wizard/src/__tests__/interface.test.ts b/packages/wizard/src/__tests__/interface.test.ts index 1dcf42c55..33172a8b8 100644 --- a/packages/wizard/src/__tests__/interface.test.ts +++ b/packages/wizard/src/__tests__/interface.test.ts @@ -72,6 +72,42 @@ describe('wizardCanUseTool', () => { }) }) + describe('.env templates are not sensitive', () => { + // The doctrine tells the agent to add placeholder keys to `.env.example`. + // The guard covers Edit and Write as well as Read, so a blanket `.env.` + // rule made the file the agent is told to write unreachable. + it.each(['.env.example', '.env.sample', '.env.template'])( + 'allows Read/Edit/Write/Glob on %s', + (name) => { + expect(wizardCanUseTool('Read', { file_path: `/project/${name}` })).toBe( + true, + ) + expect(wizardCanUseTool('Edit', { file_path: `/project/${name}` })).toBe( + true, + ) + expect( + wizardCanUseTool('Write', { file_path: `/project/${name}` }), + ).toBe(true) + expect(wizardCanUseTool('Glob', { pattern: name })).toBe(true) + }, + ) + + it('still blocks value-bearing files that only start with a template name', () => { + expect( + wizardCanUseTool('Read', { file_path: '/project/.env.example.local' }), + ).toContain('blocked') + expect( + wizardCanUseTool('Write', { file_path: '/project/.env.example.bak' }), + ).toContain('blocked') + }) + + it('still blocks env templates from Bash — Read/Write is the sanctioned path', () => { + expect( + wizardCanUseTool('Bash', { command: 'cat .env.example' }), + ).toContain('.env') + }) + }) + describe('Bash commands', () => { it('allows allowlisted npm commands', () => { expect( diff --git a/packages/wizard/src/agent/interface.ts b/packages/wizard/src/agent/interface.ts index b4d7b2abf..e70c8e481 100644 --- a/packages/wizard/src/agent/interface.ts +++ b/packages/wizard/src/agent/interface.ts @@ -114,7 +114,17 @@ const ALLOWED_WRITE_PATHS = [ /** Sensitive file patterns the agent must not read directly. */ const SENSITIVE_FILE_PATTERNS = [ - /\.env($|\.)/, // .env, .env.local, .env.production, etc. + // `.env`, `.env.local`, `.env.production`, … but not the committed templates + // `.env.example` / `.env.sample` / `.env.template`. Those carry placeholder + // key names rather than values, and the agent doctrine + // (`packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md`, invariant 3) + // instructs the agent to create and edit them — a blanket `.env.` rule made + // that impossible, since this guard also covers Edit and Write. + // + // The negative lookahead is anchored, so a value-bearing file that merely + // starts with a template name (`.env.example.bak`, `.env.example.local`) + // stays blocked. + /\.env($|\.(?!(example|sample|template)$))/, /auth\.json$/, // ~/.cipherstash/auth.json /secretkey\.json$/, // ~/.cipherstash/secretkey.json /credentials/i, // Various credential files @@ -178,7 +188,9 @@ export function wizardCanUseTool( return 'Multi-line commands are not allowed for security reasons.' } - // Block direct .env access via Bash + // Block direct .env access via Bash. Deliberately stricter than the + // file-path guard above: `.env.example` is reachable through Read/Edit/Write, + // so there is no reason for the agent to touch any env file from a shell. if (/\.(env|env\.local)/.test(command)) { return 'Direct .env file access via Bash is blocked. Use the wizard-tools MCP server instead.' }