Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/stash-prisma-next-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'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<Integration>` 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.

`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.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Agent Skills — these ship to customers

Expand All @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
39 changes: 39 additions & 0 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<InstallOptions, 'force'>,
): 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.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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'])
})
})

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/init/lib/build-agents-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)}`)
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/init/lib/install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,27 @@ import { findBundledDir } from './bundled-paths.js'
export const SKILL_MAP: Record<Integration, readonly string[]> = {
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<Integration, …>`, 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 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
}

/**
* Copy the per-integration set of skills into `<cwd>/<destDir>/<skill>/`.
*
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/tests/e2e/eql-install-prisma-next.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading