From 2563c649ee7924b6ab6e23b073a803d7a766b274 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 16:20:00 +1000 Subject: [PATCH 1/3] fix(cli): stop init/plan/impl crashing on Prisma Next; ship stash-prisma-next skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL_MAP was typed Record but omitted the 'prisma-next' key, so installSkills and the AGENTS.md builder hit SKILL_MAP['prisma-next'] = undefined and threw 'not iterable' for any repo the CLI detected as Prisma Next. tsc catches the missing key, but the build (tsup) transpiles without type-checking, so the error shipped in rc.2 — reachable by any Prisma user running stash init (Prisma Next is auto-detected). - Add the prisma-next SKILL_MAP entry. - Route both consumers through a new skillsFor() helper that degrades an unmapped integration to the base skill set (stash-encryption + stash-cli) instead of crashing — belt-and-braces for the next Integration variant, since tsup won't type-check the map. - New skills/stash-prisma-next/SKILL.md documenting the EQL v3 Prisma Next surface: domain-named column types (EncryptedTextSearch, EncryptedDoubleOrd, ...), cipherstashFromStackV3 wiring, runtime envelopes, the eql* operators, and EQL install via prisma-next migration apply (not stash eql install). - Tests: SKILL_MAP has a non-empty entry for every integration; skillsFor falls back for an unmapped one; buildAgentsMdBody('prisma-next', ...) inlines the skill (the exact call that used to crash); installSkills copies it. - Meta: AGENTS.md skill-map table + skills list updated. Companion to #655 (the @cipherstash/prisma-next EQL v3 package) — that PR releases the package but touches no CLI code, so this crash survives it. Stacked on feat/prisma-next-eql-v3; retarget to main when #655 lands. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/stash-prisma-next-skill.md | 19 ++ AGENTS.md | 4 +- .../lib/__tests__/build-agents-md.test.ts | 9 + .../init/lib/__tests__/install-skills.test.ts | 43 ++++ .../src/commands/init/lib/build-agents-md.ts | 4 +- .../src/commands/init/lib/install-skills.ts | 20 +- skills/stash-prisma-next/SKILL.md | 234 ++++++++++++++++++ 7 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 .changeset/stash-prisma-next-skill.md create mode 100644 skills/stash-prisma-next/SKILL.md diff --git a/.changeset/stash-prisma-next-skill.md b/.changeset/stash-prisma-next-skill.md new file mode 100644 index 000000000..289d44a71 --- /dev/null +++ b/.changeset/stash-prisma-next-skill.md @@ -0,0 +1,19 @@ +--- +'stash': minor +--- + +`stash init`, `stash plan`, and `stash impl` no longer crash on a Prisma Next +project. `SKILL_MAP` was missing a `prisma-next` entry, so the skills-install +and AGENTS.md-builder steps hit `SKILL_MAP[integration]` → `undefined` and threw +"not iterable" for any repo the CLI detected as Prisma Next. The entry is added +and both consumers now resolve skills through a `skillsFor()` helper that +degrades an unmapped integration to the base skill set instead of crashing +(`tsup` ships without type-checking, so the `Record` type alone +didn't protect the build). + +Ships a new **`stash-prisma-next`** agent skill documenting the EQL v3 Prisma +Next surface — the domain-named encrypted column types (`EncryptedTextSearch`, +`EncryptedDoubleOrd`, …), `cipherstashFromStackV3` wiring, the runtime value +envelopes, the `eql*` query operators, and EQL installation via +`prisma-next migration apply`. It is installed for Prisma Next projects and +inlined into `AGENTS.md` for editor agents. diff --git a/AGENTS.md b/AGENTS.md index 4b14ad905..6ce4c707d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ If these variables are missing, tests that require live encryption will fail or - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). -- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-supply-chain-security`) +- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`) ## Agent Skills — these ship to customers @@ -115,7 +115,7 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours. |---|---| | `packages/cli` commands, flags, or prompts | `skills/stash-cli` | | `packages/stack` encryption API, schema builders, subpath exports | `skills/stash-encryption` | -| Drizzle / Supabase / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-dynamodb` | +| Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` | | The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` | | pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` | | The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` | diff --git a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts index 9aebdb34e..b87532e13 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts @@ -51,4 +51,13 @@ describe('buildAgentsMdBody', () => { expect(out).not.toContain('# Skill: stash-drizzle') expect(out).not.toContain('# Skill: stash-supabase') }) + + it('prisma-next inlines the stash-prisma-next skill (no crash on undefined map)', () => { + // Regression: SKILL_MAP once lacked a prisma-next key, so this call + // threw "SKILL_MAP[integration] is not iterable" for any Prisma repo. + const out = buildAgentsMdBody('prisma-next', 'doctrine-plus-skills') + expect(out).toContain('# Skill: stash-prisma-next') + expect(out).toContain('# Skill: stash-encryption') + expect(out).not.toContain('# Skill: stash-drizzle') + }) }) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index cefb345bb..1a1db19ec 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -2,13 +2,35 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { Integration } from '../../types.js' import { installSkills, readBundledSkill, SKILL_MAP, + skillsFor, } from '../install-skills.js' +// Every integration the init flow can resolve to. Kept in lockstep with the +// `Integration` union and the init provider registry — a new integration added +// without a SKILL_MAP entry crashes the skills install and the AGENTS.md builder +// (SKILL_MAP[integration] is undefined → "not iterable"), and `tsup` ships that +// without type-checking, so this list is the runtime guard tsc can't be. +const ALL_INTEGRATIONS: Integration[] = [ + 'drizzle', + 'supabase', + 'prisma-next', + 'postgresql', +] + describe('SKILL_MAP', () => { + it('has a non-empty entry for every integration (no undefined → crash)', () => { + for (const integration of ALL_INTEGRATIONS) { + const skills = SKILL_MAP[integration] + expect(skills, integration).toBeDefined() + expect(skills.length, integration).toBeGreaterThan(0) + } + }) + it('always includes stash-encryption and stash-cli for every integration', () => { for (const [integration, skills] of Object.entries(SKILL_MAP)) { expect(skills, integration).toContain('stash-encryption') @@ -24,9 +46,30 @@ describe('SKILL_MAP', () => { expect(SKILL_MAP.supabase).toContain('stash-supabase') }) + it('prisma-next includes stash-prisma-next', () => { + expect(SKILL_MAP['prisma-next']).toContain('stash-prisma-next') + }) + it('postgresql skips ORM-specific skills', () => { expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle') expect(SKILL_MAP.postgresql).not.toContain('stash-supabase') + expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next') + }) +}) + +describe('skillsFor', () => { + it('returns the mapped skills for a known integration', () => { + expect(skillsFor('prisma-next')).toEqual([ + 'stash-encryption', + 'stash-prisma-next', + 'stash-cli', + ]) + }) + + it('falls back to the base skills for an unmapped integration (never crashes)', () => { + // Simulate a future Integration variant with no SKILL_MAP entry. + const skills = skillsFor('mystery-orm' as Integration) + expect(skills).toEqual(['stash-encryption', 'stash-cli']) }) }) diff --git a/packages/cli/src/commands/init/lib/build-agents-md.ts b/packages/cli/src/commands/init/lib/build-agents-md.ts index 2885d6370..fcdadf999 100644 --- a/packages/cli/src/commands/init/lib/build-agents-md.ts +++ b/packages/cli/src/commands/init/lib/build-agents-md.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import * as p from '@clack/prompts' import type { Integration } from '../types.js' import { findBundledDir } from './bundled-paths.js' -import { readBundledSkill, SKILL_MAP } from './install-skills.js' +import { readBundledSkill, skillsFor } from './install-skills.js' export type AgentsMdMode = 'doctrine-only' | 'doctrine-plus-skills' @@ -41,7 +41,7 @@ export function buildAgentsMdBody( if (mode === 'doctrine-plus-skills') { const skillBodies: string[] = [] - for (const name of SKILL_MAP[integration]) { + for (const name of skillsFor(integration)) { const body = readBundledSkill(name) if (body) { skillBodies.push(`---\n\n# Skill: ${name}\n\n${stripFrontmatter(body)}`) diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts index ce368e11b..9cb104108 100644 --- a/packages/cli/src/commands/init/lib/install-skills.ts +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -13,9 +13,27 @@ import { findBundledDir } from './bundled-paths.js' export const SKILL_MAP: Record = { drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'], supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'], + 'prisma-next': ['stash-encryption', 'stash-prisma-next', 'stash-cli'], postgresql: ['stash-encryption', 'stash-cli'], } +/** The skills every integration gets — the safe fallback for an unmapped one. */ +const BASE_SKILLS: readonly string[] = ['stash-encryption', 'stash-cli'] + +/** + * Skills for an integration, resilient to an unmapped one. `SKILL_MAP` is + * typed `Record`, but the build (`tsup`) transpiles without + * type-checking — so a new `Integration` variant added without a `SKILL_MAP` + * entry would ship as `undefined` and crash both consumers (`installSkills`, + * the AGENTS.md builder) with "not iterable". Degrade to the base skill set + * instead: the user still gets `stash-encryption` + `stash-cli`, never a + * stack trace. (Regression-guarded by a test that pins SKILL_MAP's keys to + * the init provider registry.) + */ +export function skillsFor(integration: Integration): readonly string[] { + return SKILL_MAP[integration] ?? BASE_SKILLS +} + /** * Copy the per-integration set of skills into `///`. * @@ -34,7 +52,7 @@ export function installSkills( destDir: string, integration: Integration, ): string[] { - const skills = SKILL_MAP[integration] + const skills = skillsFor(integration) const bundledRoot = findBundledDir('skills') if (!bundledRoot) { p.log.warn( diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md new file mode 100644 index 000000000..ab1f41ef1 --- /dev/null +++ b/skills/stash-prisma-next/SKILL.md @@ -0,0 +1,234 @@ +--- +name: stash-prisma-next +description: Integrate CipherStash searchable field-level encryption with Prisma Next using @cipherstash/prisma-next (EQL v3). Covers the domain-named encrypted column types in schema.prisma (EncryptedTextSearch, EncryptedDoubleOrd, EncryptedBigIntOrd, EncryptedDateOrd, EncryptedBoolean, EncryptedJson), the one-call cipherstashFromStackV3 wiring, the runtime value envelopes (EncryptedString/Number/BigInt/Date/Boolean/Json) and decryptAll, the eql* query operators (eqlEq, eqlMatch, eqlGt, eqlBetween, eqlIn, eqlJsonContains, eqlAsc/eqlDesc), EQL bundle installation via prisma-next migration apply, and authentication. Use when adding encryption to a Prisma Next project or querying encrypted columns. +--- + +# CipherStash Stack — Prisma Next Integration + +Guide for searchable field-level encryption in a **Prisma Next** app with +`@cipherstash/prisma-next` (EQL v3), powered by `@cipherstash/stack`. You declare +encrypted columns directly in `schema.prisma`; Prisma Next's migration system +installs the EQL bundle in the same sweep that creates your tables — there is no +separate `stash eql install` step. + +> This is the **EQL v3** surface (the documented one). A legacy EQL v2 surface +> exists for existing deployments (`cipherstashFromStack` from +> `@cipherstash/prisma-next/stack`, `cipherstash*` operators); everything below +> is v3. New projects use v3. + +In EQL v3 every encrypted column is a **concrete Postgres domain** +(`public.eql_v3_text_search`, `public.eql_v3_double_ord`, …) whose query +capabilities are fixed by the column type you choose — there is no capability +config object. See the `stash-encryption` skill for the domain catalog and +capability semantics; this skill covers the Prisma-Next-specific surface. + +## When to Use This Skill + +- Adding field-level encryption to a Prisma Next project +- Declaring encrypted columns in `schema.prisma` +- Querying encrypted columns with the `eql*` operators +- Wiring the runtime with `cipherstashFromStackV3` + +## Installation + +```bash +npm install @cipherstash/stack @cipherstash/prisma-next +``` + +Or run `npx stash init --prisma-next`, which detects Prisma Next, installs both +packages pinned to the CLI release, authenticates, and scaffolds the wiring. + +## The three wiring points + +### 1. Declare encrypted columns in `schema.prisma` + +The column types are **domain-named** — the name encodes the query capability +(matching the `@cipherstash/stack` `types.*` catalog), not a generic primitive: + +```prisma +model User { + id String @id + email cipherstash.EncryptedTextSearch() // eq + range + free-text + ORDER BY + salary cipherstash.EncryptedDoubleOrd() // eq + range + ORDER BY + accountId cipherstash.EncryptedBigIntOrd() // eq + range + ORDER BY + birthday cipherstash.EncryptedDateOrd() // eq + range + ORDER BY + emailVerified cipherstash.EncryptedBoolean() // storage-only (no operators) + preferences cipherstash.EncryptedJson() // containment (@>) +} +``` + +| Column type | Domain | Query capability | +|---|---|---| +| `EncryptedTextSearch()` | `eql_v3_text_search` | equality, range, free-text, ORDER BY | +| `EncryptedDoubleOrd()` | `eql_v3_double_ord` | equality, range, ORDER BY | +| `EncryptedBigIntOrd()` | `eql_v3_bigint_ord` | equality, range, ORDER BY | +| `EncryptedDateOrd()` | `eql_v3_date_ord` | equality, range, ORDER BY | +| `EncryptedBoolean()` | `eql_v3_boolean` | storage-only (no operators) | +| `EncryptedJson()` | `eql_v3_json` | containment (`@>`) | + +Choose the column type by the queries you need: a value you only store and +decrypt (never search) can use a storage-only domain; a value you filter or sort +on needs the matching `*Ord` / `TextSearch` domain. The type is fixed at the +column — there is no capability tuner. + +### 2. Register the extension pack in `prisma-next.config.ts` + +```typescript +import cipherstash from '@cipherstash/prisma-next/control' +import { defineConfig } from 'prisma-next' +// ... family, target, adapter, contract + +export default defineConfig({ + // ... your existing config + extensionPacks: [cipherstash], +}) +``` + +### 3. Wire the runtime with `cipherstashFromStackV3` in `src/db.ts` + +```typescript +import 'dotenv/config' +import { cipherstashFromStackV3 } from '@cipherstash/prisma-next/v3' +import postgres from '@prisma-next/postgres/runtime' +import type { Contract } from './prisma/contract.d' +import contractJson from './prisma/contract.json' with { type: 'json' } + +const cipherstash = await cipherstashFromStackV3({ contractJson }) + +export const db = postgres({ + contractJson, + extensions: cipherstash.extensions, + middleware: cipherstash.middleware, +}) +``` + +`cipherstashFromStackV3({ contractJson })` derives the v3 encryption schemas from +the contract (one `public.eql_v3_*` domain per column), constructs the +`@cipherstash/stack` `EncryptionV3` client from your `CS_*` env vars or local +profile, builds the SDK adapter, and returns ready-to-spread `extensions` and +`middleware`. A v3 client is v3-only — a contract carrying v2 codec ids is +rejected at setup (use `cipherstashFromStack` from +`@cipherstash/prisma-next/stack` for a v2 contract). + +## Install the EQL bundle (part of your migration, not a separate step) + +The extension pack contributes its own contract space at +`migrations/cipherstash/`, so the EQL bundle installs alongside your application +schema: + +```bash +npx stash auth login # one-time, per developer +npx prisma-next contract emit +npx prisma-next migration plan --name initial +npx prisma-next migration apply # installs EQL bundle + your schema +``` + +Do **not** run `stash eql install` for a Prisma Next project — `prisma-next +migration apply` owns EQL installation, and `stash init --prisma-next` skips the +standalone installer for exactly this reason. + +## Writing and reading encrypted values + +At the value boundary you wrap plaintext in a **runtime envelope** (primitive-named, +distinct from the domain-named column type) and unwrap with `decryptAll` + +`.decrypt()`: + +```typescript +import { + decryptAll, + EncryptedString, EncryptedNumber, EncryptedBigInt, + EncryptedDate, EncryptedBoolean, EncryptedJson, +} from '@cipherstash/prisma-next/runtime' + +await db.orm.public.User.create({ + id: 'user-0', + email: EncryptedString.from('alice@example.com'), + salary: EncryptedNumber.from(100_000), // EncryptedDoubleOrd column + accountId: EncryptedBigInt.from(100_000_000_001n), + birthday: EncryptedDate.from(new Date('1990-01-01')), + emailVerified: EncryptedBoolean.from(true), + preferences: EncryptedJson.from({ theme: 'dark' }), +}) + +const rows = await db.orm.public.User.where((u) => u.email.eqlEq('alice@example.com')).all() +await decryptAll(rows) // batches one SDK round-trip per (table,column) +console.log(await rows[0]?.email.decrypt()) // 'alice@example.com' +``` + +The envelope for a `double` column is `EncryptedNumber` (JS `number`); the schema +column type is `EncryptedDoubleOrd`. Envelope ↔ column pairing: `EncryptedString` +↔ `EncryptedTextSearch`, `EncryptedNumber` ↔ `EncryptedDoubleOrd`, +`EncryptedBigInt` ↔ `EncryptedBigIntOrd`, `EncryptedDate` ↔ `EncryptedDateOrd`, +`EncryptedBoolean` ↔ `EncryptedBoolean`, `EncryptedJson` ↔ `EncryptedJson`. + +## Query operators (`eql*`) + +Operators live on the encrypted column inside `.where((u) => …)` and encrypt the +search term for you — Prisma Next never sees plaintext in a query. EQL v3 uses the +EQL-derived `eql*` vocabulary (the legacy v2 surface keeps `cipherstash*` names): + +| Operator | Meaning | Requires | +|---|---|---| +| `eqlEq(v)` / `eqlNeq(v)` | equality / inequality | any searchable domain | +| `eqlIn(vs)` / `eqlNotIn(vs)` | membership | any searchable domain | +| `eqlMatch(term)` | free-text token match (`eql_v3.contains`) | `EncryptedTextSearch` | +| `eqlGt/eqlGte/eqlLt/eqlLte(v)` | range comparison | an `*Ord` domain | +| `eqlBetween(lo,hi)` / `eqlNotBetween(lo,hi)` | range window | an `*Ord` domain | +| `eqlAsc()` / `eqlDesc()` | ORDER BY | an `*Ord` or `TextSearch` domain | +| `eqlJsonContains(obj)` | encrypted JSON containment (`@>`) | `EncryptedJson` | + +```typescript +// range +await db.orm.public.User.where((u) => u.salary.eqlGt(100_000)).all() +// free-text +await db.orm.public.User.where((u) => u.email.eqlMatch('example.com')).all() +// between +await db.orm.public.User.where((u) => u.birthday.eqlBetween(lo, hi)).all() +// bigint membership +await db.orm.public.User.where((u) => u.accountId.eqlIn([100_000_000_001n])).all() +// encrypted JSON containment +await db.orm.public.User.where((u) => u.preferences.eqlJsonContains({ theme: 'dark' })).all() +// ordering +import { eqlAsc } from '@cipherstash/prisma-next/runtime' +await db.orm.public.User.orderBy((u) => eqlAsc(u.salary)).all() +``` + +Applying an operator its domain doesn't support (e.g. `eqlGt` on a +storage-only `EncryptedBoolean`, or `eqlMatch` on a non-text domain) is a typed +error at build time, not a runtime surprise. + +## Authentication + +Same credential model as the rest of Stack: + +- **Local dev:** `npx stash auth login` (device-code flow; token in `~/.cipherstash`). +- **CI / production:** the four `CS_*` env vars (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, + `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`). See the `stash-cli` and + `stash-encryption` skills for how to obtain them from your device session. + +`cipherstashFromStackV3` resolves `CS_*` when present, else the local profile. + +## Bundling + +`@cipherstash/stack` wraps a native FFI module and must be excluded from bundling +(`serverExternalPackages`, esbuild `external`, etc.) — see the `stash-encryption` +skill's bundling section. For edge/serverless runtimes without the native module, +use `@cipherstash/stack/wasm-inline`. + +## Subpath exports + +| Subpath | Purpose | +|---|---| +| `@cipherstash/prisma-next/v3` | The v3 surface: `cipherstashFromStackV3`, the SDK adapter, envelopes/middleware | +| `@cipherstash/prisma-next/control` | The extension pack for `extensionPacks: [...]` | +| `@cipherstash/prisma-next/runtime` | Envelope classes, `decryptAll`, `eql*` operators, `EncryptedString.from()`… | +| `@cipherstash/prisma-next/stack` | Legacy EQL v2 one-call setup (`cipherstashFromStack`) | + +## Gotchas + +- **EQL installs via `prisma-next migration apply`, never `stash eql install`.** +- **Column type (schema, domain-named) ≠ runtime envelope (value, primitive-named).** + `EncryptedDoubleOrd` column ↔ `EncryptedNumber.from(...)` value. +- **A v3 client rejects a v2 contract** at `cipherstashFromStackV3`. Regenerate the + contract (`prisma-next contract emit`) after switching a column to a v3 type. +- **Never log or read `~/.cipherstash`** or `.env*` credential files (see `stash-cli`). From 229b174d1aabd9cfaadf3f7f9561cfc203ca4cc4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 17:13:28 +1000 Subject: [PATCH 2/3] docs(skills): fix three factual errors in stash-prisma-next flagged on #683 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init does NOT scaffold wiring for Prisma Next (it skips the encryption-client scaffold — schema is derived from contract.json); say so instead of 'scaffolds the wiring'. (Copilot) - eqlAsc/eqlDesc are free functions taking the column expression (eqlAsc(u.salary)), not zero-arg methods; fix the ORDER BY table row to match the code example. (Copilot) - install-skills.ts: the skillsFor regression guard is a test asserting SKILL_MAP has an entry for every value in a maintained ALL_INTEGRATIONS list, not one that reads the provider registry; correct the comment. (Copilot) Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- packages/cli/src/commands/init/lib/install-skills.ts | 4 ++-- skills/stash-prisma-next/SKILL.md | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts index 9cb104108..7c16310e7 100644 --- a/packages/cli/src/commands/init/lib/install-skills.ts +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -27,8 +27,8 @@ const BASE_SKILLS: readonly string[] = ['stash-encryption', 'stash-cli'] * entry would ship as `undefined` and crash both consumers (`installSkills`, * the AGENTS.md builder) with "not iterable". Degrade to the base skill set * instead: the user still gets `stash-encryption` + `stash-cli`, never a - * stack trace. (Regression-guarded by a test that pins SKILL_MAP's keys to - * the init provider registry.) + * stack trace. (Regression-guarded by a test asserting SKILL_MAP has a + * non-empty entry for every value in a maintained `ALL_INTEGRATIONS` list.) */ export function skillsFor(integration: Integration): readonly string[] { return SKILL_MAP[integration] ?? BASE_SKILLS diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md index ab1f41ef1..6a4fa83e6 100644 --- a/skills/stash-prisma-next/SKILL.md +++ b/skills/stash-prisma-next/SKILL.md @@ -36,7 +36,10 @@ npm install @cipherstash/stack @cipherstash/prisma-next ``` Or run `npx stash init --prisma-next`, which detects Prisma Next, installs both -packages pinned to the CLI release, authenticates, and scaffolds the wiring. +packages pinned to the CLI release, and authenticates. It does **not** scaffold +the wiring files — Prisma Next derives its schema from `contract.json`, so there +is no encryption-client file to generate; init prints the next steps (declare +encrypted columns, emit the contract, run the migration) instead. ## The three wiring points @@ -174,7 +177,7 @@ EQL-derived `eql*` vocabulary (the legacy v2 surface keeps `cipherstash*` names) | `eqlMatch(term)` | free-text token match (`eql_v3.contains`) | `EncryptedTextSearch` | | `eqlGt/eqlGte/eqlLt/eqlLte(v)` | range comparison | an `*Ord` domain | | `eqlBetween(lo,hi)` / `eqlNotBetween(lo,hi)` | range window | an `*Ord` domain | -| `eqlAsc()` / `eqlDesc()` | ORDER BY | an `*Ord` or `TextSearch` domain | +| `eqlAsc(col)` / `eqlDesc(col)` | ORDER BY (free functions, take the column) | an `*Ord` or `TextSearch` domain | | `eqlJsonContains(obj)` | encrypted JSON containment (`@>`) | `EncryptedJson` | ```typescript From 65da67c70490d58fe44199fe9870d6d5ce7ea9ae Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 17:23:13 +1000 Subject: [PATCH 3/3] feat(cli): refuse `stash eql install` in a Prisma Next project (#683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma Next installs the EQL bundle through its own migration ledger (migrations/cipherstash/ applied by `prisma-next migration apply`), so the standalone installer is the wrong tool — running it applies EQL out-of-band from the framework's ledger. `stash init --prisma-next` already skips the installer; this closes the manual-invocation hole. - New pure `prismaNextInstallGuard(cwd, { force })` (mirrors validateInstallFlags): returns actionable guidance when a Prisma Next project is detected and --force isn't set, else null. Called early in installCommand, before any DB I/O — fails fast with a pointer to `prisma-next migration apply`. - --force overrides (deliberate standalone install escape hatch). - messages.eql.prismaNextDetected as the stable leader. - Tests: 4 unit (config-file detection, dep detection, --force override, non-prisma-next passthrough) + 1 pty-less e2e proving the wiring (refuses, exit 1, no DB). Skill gotcha + changeset updated. Addresses CJ/Dan's #683 review ask to enforce this in the CLI. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/stash-prisma-next-skill.md | 5 ++ .../prisma-next-install-guard.test.ts | 54 +++++++++++++++++++ packages/cli/src/commands/db/install.ts | 39 ++++++++++++++ packages/cli/src/messages.ts | 7 +++ .../e2e/eql-install-prisma-next.e2e.test.ts | 35 ++++++++++++ skills/stash-prisma-next/SKILL.md | 4 +- 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/db/__tests__/prisma-next-install-guard.test.ts create mode 100644 packages/cli/tests/e2e/eql-install-prisma-next.e2e.test.ts diff --git a/.changeset/stash-prisma-next-skill.md b/.changeset/stash-prisma-next-skill.md index 289d44a71..a1617d521 100644 --- a/.changeset/stash-prisma-next-skill.md +++ b/.changeset/stash-prisma-next-skill.md @@ -17,3 +17,8 @@ Next surface — the domain-named encrypted column types (`EncryptedTextSearch`, envelopes, the `eql*` query operators, and EQL installation via `prisma-next migration apply`. It is installed for Prisma Next projects and inlined into `AGENTS.md` for editor agents. + +`stash eql install` now refuses to run in a Prisma Next project (pointing you +at `prisma-next migration apply`, which owns EQL installation) unless you pass +`--force` — closing the manual-invocation hole that `stash init --prisma-next` +already avoided. diff --git a/packages/cli/src/commands/db/__tests__/prisma-next-install-guard.test.ts b/packages/cli/src/commands/db/__tests__/prisma-next-install-guard.test.ts new file mode 100644 index 000000000..6661faf3c --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/prisma-next-install-guard.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { messages } from '../../../messages.js' +import { prismaNextInstallGuard } from '../install.js' + +describe('prismaNextInstallGuard', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pn-install-guard-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('blocks with actionable guidance when a prisma-next.config is present', () => { + writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}') + + const msg = prismaNextInstallGuard(dir, {}) + expect(msg).not.toBeNull() + expect(msg).toContain(messages.eql.prismaNextDetected) + expect(msg).toContain('prisma-next migration apply') + expect(msg).toContain('--force') + }) + + it('blocks when @cipherstash/prisma-next is a dependency', () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { '@cipherstash/prisma-next': '1.0.0' } }), + ) + + expect(prismaNextInstallGuard(dir, {})).toContain( + messages.eql.prismaNextDetected, + ) + }) + + it('returns null (allows) with --force, even in a prisma-next project', () => { + writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}') + + expect(prismaNextInstallGuard(dir, { force: true })).toBeNull() + }) + + it('returns null (allows) when the project is not Prisma Next', () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@cipherstash/stack-drizzle': '1.0.0' }, + }), + ) + + expect(prismaNextInstallGuard(dir, {})).toBeNull() + }) +}) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index adcb59545..7ce8f6aa0 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -18,10 +18,12 @@ import { loadBundledEqlSql, resolveEqlVersion, } from '@/installer/index.js' +import { messages } from '@/messages.js' import { ensureEncryptionClient } from './client-scaffold.js' import { offerStashConfig } from './config-scaffold.js' import { detectDrizzle, + detectPrismaNext, detectSupabase, detectSupabaseProject, type SupabaseProjectInfo, @@ -181,6 +183,17 @@ export async function installCommand(options: InstallOptions) { process.exit(1) } + // Prisma Next owns EQL installation via its own migration system, so the + // standalone installer is the wrong tool here. Refuse (before any DB I/O) + // unless --force. Fires fast so a user who typed the wrong command gets a + // pointer, not a half-applied install. + const prismaNextBlock = prismaNextInstallGuard(process.cwd(), options) + if (prismaNextBlock) { + p.log.error(prismaNextBlock) + p.outro('Installation aborted.') + process.exit(1) + } + const s = p.spinner() // `eql install` only needs a database URL to install EQL. It does NOT require @@ -629,6 +642,32 @@ export function routeInstallPathForEqlVersion( } } +/** + * `stash eql install` is the wrong tool in a Prisma Next project: Prisma Next + * contributes a `migrations/cipherstash/` control space that installs the EQL + * bundle as part of `prisma-next migration apply`, in the same ledger as the + * app schema. Running the standalone installer applies EQL out-of-band from + * that ledger. `stash init --prisma-next` already skips the installer; this + * guards the manual-invocation path too. + * + * Returns the guidance string when the install should be blocked, else null. + * `--force` overrides (an escape hatch for a deliberate standalone install). + * Pure + cwd-injected so it unit-tests without a real project. + */ +export function prismaNextInstallGuard( + cwd: string, + options: Pick, +): string | null { + if (options.force) return null + if (!detectPrismaNext(cwd)) return null + return ( + `${messages.eql.prismaNextDetected} (found prisma-next.config.* or @cipherstash/prisma-next). ` + + 'Prisma Next installs the EQL bundle through its own migration system — run ' + + '`prisma-next migration apply` instead of `stash eql install`. ' + + 'Pass --force to run the standalone installer against this database anyway.' + ) +} + export function validateInstallFlags(options: InstallOptions): string | null { if (options.migration && options.direct) { return '`--migration` and `--direct` are mutually exclusive. Pick one.' diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 00ce16e39..90bc52bb3 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -59,6 +59,13 @@ export const messages = { }, eql: { unknownSubcommand: 'Unknown eql subcommand', + /** + * Stable leader of the guard shown when `stash eql install` runs in a + * Prisma Next project — Prisma Next owns EQL installation via its own + * migration system, so the standalone installer is the wrong tool. The + * actionable command + `--force` note are appended at the call site. + */ + prismaNextDetected: 'This looks like a Prisma Next project', }, db: { unknownSubcommand: 'Unknown db subcommand', diff --git a/packages/cli/tests/e2e/eql-install-prisma-next.e2e.test.ts b/packages/cli/tests/e2e/eql-install-prisma-next.e2e.test.ts new file mode 100644 index 000000000..457eeb673 --- /dev/null +++ b/packages/cli/tests/e2e/eql-install-prisma-next.e2e.test.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * `stash eql install` must refuse in a Prisma Next project (Prisma Next owns + * EQL installation via its own migration ledger). The guard fires before any + * database I/O, so this needs no DB — and proves the wiring end-to-end (the + * pure guard is unit-tested separately). + */ +describe('stash eql install — Prisma Next guard', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'eql-install-pn-e2e-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('refuses with actionable guidance and exits 1 (no DB needed)', async () => { + writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}') + + const r = await runPiped(['eql', 'install'], { cwd: dir, timeoutMs: 15000 }) + + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const out = r.stdout + r.stderr + expect(out).toContain(messages.eql.prismaNextDetected) + expect(out).toContain('prisma-next migration apply') + expect(out).toContain('--force') + }) +}) diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md index 6a4fa83e6..2a03ef30b 100644 --- a/skills/stash-prisma-next/SKILL.md +++ b/skills/stash-prisma-next/SKILL.md @@ -128,7 +128,9 @@ npx prisma-next migration apply # installs EQL bundle + your schema Do **not** run `stash eql install` for a Prisma Next project — `prisma-next migration apply` owns EQL installation, and `stash init --prisma-next` skips the -standalone installer for exactly this reason. +standalone installer for exactly this reason. The CLI enforces this: `stash eql +install` detects a Prisma Next project and refuses (pointing you at `prisma-next +migration apply`) unless you pass `--force`. ## Writing and reading encrypted values