Skip to content

Commit 8872d1e

Browse files
authored
fix(cli): prisma-next init/plan/impl crash + stash-prisma-next skill (rc.2 M1) (#683)
* fix(cli): stop init/plan/impl crashing on Prisma Next; ship stash-prisma-next skill SKILL_MAP was typed Record<Integration, ...> 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 * docs(skills): fix three factual errors in stash-prisma-next flagged on #683 - 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 * feat(cli): refuse `stash eql install` in a Prisma Next project (#683) 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
1 parent a75513b commit 8872d1e

11 files changed

Lines changed: 473 additions & 5 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
`stash init`, `stash plan`, and `stash impl` no longer crash on a Prisma Next
6+
project. `SKILL_MAP` was missing a `prisma-next` entry, so the skills-install
7+
and AGENTS.md-builder steps hit `SKILL_MAP[integration]``undefined` and threw
8+
"not iterable" for any repo the CLI detected as Prisma Next. The entry is added
9+
and both consumers now resolve skills through a `skillsFor()` helper that
10+
degrades an unmapped integration to the base skill set instead of crashing
11+
(`tsup` ships without type-checking, so the `Record<Integration>` type alone
12+
didn't protect the build).
13+
14+
Ships a new **`stash-prisma-next`** agent skill documenting the EQL v3 Prisma
15+
Next surface — the domain-named encrypted column types (`EncryptedTextSearch`,
16+
`EncryptedDoubleOrd`, …), `cipherstashFromStackV3` wiring, the runtime value
17+
envelopes, the `eql*` query operators, and EQL installation via
18+
`prisma-next migration apply`. It is installed for Prisma Next projects and
19+
inlined into `AGENTS.md` for editor agents.
20+
21+
`stash eql install` now refuses to run in a Prisma Next project (pointing you
22+
at `prisma-next migration apply`, which owns EQL installation) unless you pass
23+
`--force` — closing the manual-invocation hole that `stash init --prisma-next`
24+
already avoided.

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ If these variables are missing, tests that require live encryption will fail or
9292
- `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README)
9393
- `examples/*`: Working apps (basic, prisma, supabase-worker)
9494
- `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo).
95-
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-supply-chain-security`)
95+
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)
9696

9797
## Agent Skills — these ship to customers
9898

@@ -115,7 +115,7 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours.
115115
|---|---|
116116
| `packages/cli` commands, flags, or prompts | `skills/stash-cli` |
117117
| `packages/stack` encryption API, schema builders, subpath exports | `skills/stash-encryption` |
118-
| Drizzle / Supabase / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-dynamodb` |
118+
| Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` |
119119
| The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` |
120120
| pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` |
121121
| The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` |
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { messages } from '../../../messages.js'
6+
import { prismaNextInstallGuard } from '../install.js'
7+
8+
describe('prismaNextInstallGuard', () => {
9+
let dir: string
10+
beforeEach(() => {
11+
dir = mkdtempSync(join(tmpdir(), 'pn-install-guard-'))
12+
})
13+
afterEach(() => {
14+
rmSync(dir, { recursive: true, force: true })
15+
})
16+
17+
it('blocks with actionable guidance when a prisma-next.config is present', () => {
18+
writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}')
19+
20+
const msg = prismaNextInstallGuard(dir, {})
21+
expect(msg).not.toBeNull()
22+
expect(msg).toContain(messages.eql.prismaNextDetected)
23+
expect(msg).toContain('prisma-next migration apply')
24+
expect(msg).toContain('--force')
25+
})
26+
27+
it('blocks when @cipherstash/prisma-next is a dependency', () => {
28+
writeFileSync(
29+
join(dir, 'package.json'),
30+
JSON.stringify({ dependencies: { '@cipherstash/prisma-next': '1.0.0' } }),
31+
)
32+
33+
expect(prismaNextInstallGuard(dir, {})).toContain(
34+
messages.eql.prismaNextDetected,
35+
)
36+
})
37+
38+
it('returns null (allows) with --force, even in a prisma-next project', () => {
39+
writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}')
40+
41+
expect(prismaNextInstallGuard(dir, { force: true })).toBeNull()
42+
})
43+
44+
it('returns null (allows) when the project is not Prisma Next', () => {
45+
writeFileSync(
46+
join(dir, 'package.json'),
47+
JSON.stringify({
48+
dependencies: { '@cipherstash/stack-drizzle': '1.0.0' },
49+
}),
50+
)
51+
52+
expect(prismaNextInstallGuard(dir, {})).toBeNull()
53+
})
54+
})

packages/cli/src/commands/db/install.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ import {
1818
loadBundledEqlSql,
1919
resolveEqlVersion,
2020
} from '@/installer/index.js'
21+
import { messages } from '@/messages.js'
2122
import { ensureEncryptionClient } from './client-scaffold.js'
2223
import { offerStashConfig } from './config-scaffold.js'
2324
import {
2425
detectDrizzle,
26+
detectPrismaNext,
2527
detectSupabase,
2628
detectSupabaseProject,
2729
type SupabaseProjectInfo,
@@ -181,6 +183,17 @@ export async function installCommand(options: InstallOptions) {
181183
process.exit(1)
182184
}
183185

186+
// Prisma Next owns EQL installation via its own migration system, so the
187+
// standalone installer is the wrong tool here. Refuse (before any DB I/O)
188+
// unless --force. Fires fast so a user who typed the wrong command gets a
189+
// pointer, not a half-applied install.
190+
const prismaNextBlock = prismaNextInstallGuard(process.cwd(), options)
191+
if (prismaNextBlock) {
192+
p.log.error(prismaNextBlock)
193+
p.outro('Installation aborted.')
194+
process.exit(1)
195+
}
196+
184197
const s = p.spinner()
185198

186199
// `eql install` only needs a database URL to install EQL. It does NOT require
@@ -629,6 +642,32 @@ export function routeInstallPathForEqlVersion(
629642
}
630643
}
631644

645+
/**
646+
* `stash eql install` is the wrong tool in a Prisma Next project: Prisma Next
647+
* contributes a `migrations/cipherstash/` control space that installs the EQL
648+
* bundle as part of `prisma-next migration apply`, in the same ledger as the
649+
* app schema. Running the standalone installer applies EQL out-of-band from
650+
* that ledger. `stash init --prisma-next` already skips the installer; this
651+
* guards the manual-invocation path too.
652+
*
653+
* Returns the guidance string when the install should be blocked, else null.
654+
* `--force` overrides (an escape hatch for a deliberate standalone install).
655+
* Pure + cwd-injected so it unit-tests without a real project.
656+
*/
657+
export function prismaNextInstallGuard(
658+
cwd: string,
659+
options: Pick<InstallOptions, 'force'>,
660+
): string | null {
661+
if (options.force) return null
662+
if (!detectPrismaNext(cwd)) return null
663+
return (
664+
`${messages.eql.prismaNextDetected} (found prisma-next.config.* or @cipherstash/prisma-next). ` +
665+
'Prisma Next installs the EQL bundle through its own migration system — run ' +
666+
'`prisma-next migration apply` instead of `stash eql install`. ' +
667+
'Pass --force to run the standalone installer against this database anyway.'
668+
)
669+
}
670+
632671
export function validateInstallFlags(options: InstallOptions): string | null {
633672
if (options.migration && options.direct) {
634673
return '`--migration` and `--direct` are mutually exclusive. Pick one.'

packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,13 @@ describe('buildAgentsMdBody', () => {
5151
expect(out).not.toContain('# Skill: stash-drizzle')
5252
expect(out).not.toContain('# Skill: stash-supabase')
5353
})
54+
55+
it('prisma-next inlines the stash-prisma-next skill (no crash on undefined map)', () => {
56+
// Regression: SKILL_MAP once lacked a prisma-next key, so this call
57+
// threw "SKILL_MAP[integration] is not iterable" for any Prisma repo.
58+
const out = buildAgentsMdBody('prisma-next', 'doctrine-plus-skills')
59+
expect(out).toContain('# Skill: stash-prisma-next')
60+
expect(out).toContain('# Skill: stash-encryption')
61+
expect(out).not.toContain('# Skill: stash-drizzle')
62+
})
5463
})

packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,35 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import type { Integration } from '../../types.js'
56
import {
67
installSkills,
78
readBundledSkill,
89
SKILL_MAP,
10+
skillsFor,
911
} from '../install-skills.js'
1012

13+
// Every integration the init flow can resolve to. Kept in lockstep with the
14+
// `Integration` union and the init provider registry — a new integration added
15+
// without a SKILL_MAP entry crashes the skills install and the AGENTS.md builder
16+
// (SKILL_MAP[integration] is undefined → "not iterable"), and `tsup` ships that
17+
// without type-checking, so this list is the runtime guard tsc can't be.
18+
const ALL_INTEGRATIONS: Integration[] = [
19+
'drizzle',
20+
'supabase',
21+
'prisma-next',
22+
'postgresql',
23+
]
24+
1125
describe('SKILL_MAP', () => {
26+
it('has a non-empty entry for every integration (no undefined → crash)', () => {
27+
for (const integration of ALL_INTEGRATIONS) {
28+
const skills = SKILL_MAP[integration]
29+
expect(skills, integration).toBeDefined()
30+
expect(skills.length, integration).toBeGreaterThan(0)
31+
}
32+
})
33+
1234
it('always includes stash-encryption and stash-cli for every integration', () => {
1335
for (const [integration, skills] of Object.entries(SKILL_MAP)) {
1436
expect(skills, integration).toContain('stash-encryption')
@@ -24,9 +46,30 @@ describe('SKILL_MAP', () => {
2446
expect(SKILL_MAP.supabase).toContain('stash-supabase')
2547
})
2648

49+
it('prisma-next includes stash-prisma-next', () => {
50+
expect(SKILL_MAP['prisma-next']).toContain('stash-prisma-next')
51+
})
52+
2753
it('postgresql skips ORM-specific skills', () => {
2854
expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle')
2955
expect(SKILL_MAP.postgresql).not.toContain('stash-supabase')
56+
expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next')
57+
})
58+
})
59+
60+
describe('skillsFor', () => {
61+
it('returns the mapped skills for a known integration', () => {
62+
expect(skillsFor('prisma-next')).toEqual([
63+
'stash-encryption',
64+
'stash-prisma-next',
65+
'stash-cli',
66+
])
67+
})
68+
69+
it('falls back to the base skills for an unmapped integration (never crashes)', () => {
70+
// Simulate a future Integration variant with no SKILL_MAP entry.
71+
const skills = skillsFor('mystery-orm' as Integration)
72+
expect(skills).toEqual(['stash-encryption', 'stash-cli'])
3073
})
3174
})
3275

packages/cli/src/commands/init/lib/build-agents-md.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { join } from 'node:path'
33
import * as p from '@clack/prompts'
44
import type { Integration } from '../types.js'
55
import { findBundledDir } from './bundled-paths.js'
6-
import { readBundledSkill, SKILL_MAP } from './install-skills.js'
6+
import { readBundledSkill, skillsFor } from './install-skills.js'
77

88
export type AgentsMdMode = 'doctrine-only' | 'doctrine-plus-skills'
99

@@ -41,7 +41,7 @@ export function buildAgentsMdBody(
4141

4242
if (mode === 'doctrine-plus-skills') {
4343
const skillBodies: string[] = []
44-
for (const name of SKILL_MAP[integration]) {
44+
for (const name of skillsFor(integration)) {
4545
const body = readBundledSkill(name)
4646
if (body) {
4747
skillBodies.push(`---\n\n# Skill: ${name}\n\n${stripFrontmatter(body)}`)

packages/cli/src/commands/init/lib/install-skills.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,27 @@ import { findBundledDir } from './bundled-paths.js'
1313
export const SKILL_MAP: Record<Integration, readonly string[]> = {
1414
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-cli'],
1515
supabase: ['stash-encryption', 'stash-supabase', 'stash-cli'],
16+
'prisma-next': ['stash-encryption', 'stash-prisma-next', 'stash-cli'],
1617
postgresql: ['stash-encryption', 'stash-cli'],
1718
}
1819

20+
/** The skills every integration gets — the safe fallback for an unmapped one. */
21+
const BASE_SKILLS: readonly string[] = ['stash-encryption', 'stash-cli']
22+
23+
/**
24+
* Skills for an integration, resilient to an unmapped one. `SKILL_MAP` is
25+
* typed `Record<Integration, …>`, but the build (`tsup`) transpiles without
26+
* type-checking — so a new `Integration` variant added without a `SKILL_MAP`
27+
* entry would ship as `undefined` and crash both consumers (`installSkills`,
28+
* the AGENTS.md builder) with "not iterable". Degrade to the base skill set
29+
* instead: the user still gets `stash-encryption` + `stash-cli`, never a
30+
* stack trace. (Regression-guarded by a test asserting SKILL_MAP has a
31+
* non-empty entry for every value in a maintained `ALL_INTEGRATIONS` list.)
32+
*/
33+
export function skillsFor(integration: Integration): readonly string[] {
34+
return SKILL_MAP[integration] ?? BASE_SKILLS
35+
}
36+
1937
/**
2038
* Copy the per-integration set of skills into `<cwd>/<destDir>/<skill>/`.
2139
*
@@ -34,7 +52,7 @@ export function installSkills(
3452
destDir: string,
3553
integration: Integration,
3654
): string[] {
37-
const skills = SKILL_MAP[integration]
55+
const skills = skillsFor(integration)
3856
const bundledRoot = findBundledDir('skills')
3957
if (!bundledRoot) {
4058
p.log.warn(

packages/cli/src/messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ export const messages = {
5959
},
6060
eql: {
6161
unknownSubcommand: 'Unknown eql subcommand',
62+
/**
63+
* Stable leader of the guard shown when `stash eql install` runs in a
64+
* Prisma Next project — Prisma Next owns EQL installation via its own
65+
* migration system, so the standalone installer is the wrong tool. The
66+
* actionable command + `--force` note are appended at the call site.
67+
*/
68+
prismaNextDetected: 'This looks like a Prisma Next project',
6269
},
6370
db: {
6471
unknownSubcommand: 'Unknown db subcommand',
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { messages } from '../../src/messages.js'
6+
import { runPiped } from '../helpers/spawn-piped.js'
7+
8+
/**
9+
* `stash eql install` must refuse in a Prisma Next project (Prisma Next owns
10+
* EQL installation via its own migration ledger). The guard fires before any
11+
* database I/O, so this needs no DB — and proves the wiring end-to-end (the
12+
* pure guard is unit-tested separately).
13+
*/
14+
describe('stash eql install — Prisma Next guard', () => {
15+
let dir: string
16+
beforeEach(() => {
17+
dir = mkdtempSync(join(tmpdir(), 'eql-install-pn-e2e-'))
18+
})
19+
afterEach(() => {
20+
rmSync(dir, { recursive: true, force: true })
21+
})
22+
23+
it('refuses with actionable guidance and exits 1 (no DB needed)', async () => {
24+
writeFileSync(join(dir, 'prisma-next.config.ts'), 'export default {}')
25+
26+
const r = await runPiped(['eql', 'install'], { cwd: dir, timeoutMs: 15000 })
27+
28+
expect(r.timedOut).toBe(false)
29+
expect(r.exitCode).toBe(1)
30+
const out = r.stdout + r.stderr
31+
expect(out).toContain(messages.eql.prismaNextDetected)
32+
expect(out).toContain('prisma-next migration apply')
33+
expect(out).toContain('--force')
34+
})
35+
})

0 commit comments

Comments
 (0)