From 5157404cf667bd6777d62b76862289e335034eca Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 24 Aug 2026 10:58:52 +0200 Subject: [PATCH 1/3] Bump to version 8.0.0-rc.9: the prisma package resolves the same products as @prisma/cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prisma@8.0.0-rc.8 shipped broken: the wrapper package carries its own product pins, the grammar cleanup bumped only packages/cli, and the published bin resolved @prisma/orm-toolchain@8.0.0-rc.4 — whose old family keys make the mount table's lookups undefined, so every invocation crashed constructing the tree. The conformance sandbox masked it by hoisting the good version from @prisma/cli's manifest. packages/prisma now pins composer-cli 0.12.0 and orm-toolchain 8.0.0-rc.5, matching packages/cli, and the version moves to rc.9 (rc.8 is immutable on the registry). The check gaps are recorded in the deferred ledger. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 5 +++++ package.json | 2 +- packages/cli-conformance/package.json | 4 ++-- packages/cli-engine/package.json | 4 ++-- packages/cli-telemetry/package.json | 4 ++-- packages/cli/package.json | 8 ++++---- packages/compute/package.json | 2 +- packages/prisma/package.json | 8 ++++---- packages/tsconfig/package.json | 2 +- pnpm-lock.yaml | 22 +++++++++++----------- 10 files changed, 33 insertions(+), 28 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index c5925027..075e795a 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -471,3 +471,8 @@ The agent-skills project (skills sync/list, `prisma init`, the staleness notice; - **Windows CI: `skills-sync.test.ts` timed out once at the 5s default** (run 32474645762) with a teardown ENOTEMPTY from cleanup racing the timed-out test. If it recurs, raise the suite's per-test timeout on Windows rather than chasing the race. - **`isLikelyGlobalNpmEntrypoint` (update-check.ts) matches only `prisma-cli` install paths**, so a globally-installed `prisma` gets the docs-link fallback instead of a concrete update command; `selectUpdateInstruction` still names `@prisma/cli`. Newly conspicuous after the CLI_NAME → prisma rename. - **The feedback client's user-agent changed from `prisma-cli/` to `prisma/`** — wire-visible; whoever reads that dashboard should know. +## Left open by the rc.8 broken release (2026-08-24) + +- **`prisma@8.0.0-rc.8` on npm is broken and immutable.** The `prisma` wrapper package carries its own copies of the product pins, and the grammar-cleanup branch bumped only `packages/cli/package.json` — so the published `prisma` bin resolved `@prisma/orm-toolchain@8.0.0-rc.4`, whose old family keys make the mount table's lookups undefined and every invocation crash ("Cannot read properties of undefined (reading 'needs')"). rc.9 fixes it. Consider `npm deprecate prisma@8.0.0-rc.8` (needs a maintainer's npm auth; CI publishes via OIDC and has no deprecate step). +- **The release checks did not catch a `prisma` bin that crashes on install.** "Check the release" passed on the rc.8 run: the conformance sandbox install evidently resolved the good `@prisma/orm-toolchain@8.0.0-rc.5` from `@prisma/cli`'s manifest by hoisting, masking the wrapper's stale rc.4 pin — a real `npm install prisma` hoists rc.4 instead and crashes. Two fixes, both worth doing: a check that the `prisma` and `@prisma/cli` manifests declare identical product pins (one-file script, catches the divergence class statically), and running the tarball check's bin-start against an install of the `prisma` package alone, so resolution happens the way a user's install resolves it. +- **Two manifests hand-carry the same pins.** `update-product-versions.mjs` rewrites both, but a hand-edit (this incident) can touch one. The static pin-equality check above is the guard; deriving one manifest from the other at pack time would remove the class entirely — design call. diff --git a/package.json b/package.json index e2ccce14..6e605b93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prisma-cli", - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "private": true, "engines": { "node": ">=24" diff --git a/packages/cli-conformance/package.json b/packages/cli-conformance/package.json index f2d333e0..fba846c2 100644 --- a/packages/cli-conformance/package.json +++ b/packages/cli-conformance/package.json @@ -1,7 +1,7 @@ { "name": "@repo/cli-conformance", "private": true, - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "description": "Reusable conformance checks for the engine's consumers: import purity over built output, config-section validators that never throw, and verification of the tarballs a registry would receive. Depends on no package it checks.", "type": "module", "exports": { @@ -24,7 +24,7 @@ "test": "pnpm run typecheck && vitest run" }, "devDependencies": { - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "es-module-lexer": "^2.1.0", "tsx": "^4.22.4", diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index d21997c3..21765021 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -55,8 +55,8 @@ "string-width": "^8.2.1" }, "devDependencies": { - "@repo/cli-conformance": "workspace:8.0.0-rc.8", - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@repo/cli-conformance": "workspace:8.0.0-rc.9", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "ci-info": "^4.3.1", "tsdown": "^0.21.10", diff --git a/packages/cli-telemetry/package.json b/packages/cli-telemetry/package.json index 0567350d..836ce45b 100644 --- a/packages/cli-telemetry/package.json +++ b/packages/cli-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@repo/cli-telemetry", "private": true, - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "description": "CLI telemetry child sender: the detached subprocess the engine hands a composed payload to, its system probes, and the POST", "type": "module", "sideEffects": [ @@ -35,7 +35,7 @@ "@vercel/detect-agent": "^1.2.3" }, "devDependencies": { - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "typescript": "^6.0.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index af29a097..09089b2b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli", - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "description": "Command-line interface for the Prisma Developer Platform.", "type": "module", "bin": { @@ -63,9 +63,9 @@ }, "devDependencies": { "@prisma/composer": "0.11.0", - "@repo/cli-conformance": "workspace:8.0.0-rc.8", - "@repo/cli-telemetry": "workspace:8.0.0-rc.8", - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@repo/cli-conformance": "workspace:8.0.0-rc.9", + "@repo/cli-telemetry": "workspace:8.0.0-rc.9", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "tsx": "^4.22.4", diff --git a/packages/compute/package.json b/packages/compute/package.json index 710a072e..3c69d683 100644 --- a/packages/compute/package.json +++ b/packages/compute/package.json @@ -42,7 +42,7 @@ "test": "vitest run" }, "devDependencies": { - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "typescript": "^6.0.3", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 6abda4b6..035a425e 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -1,6 +1,6 @@ { "name": "prisma", - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "description": "The Prisma CLI: one binary for the ORM, Composer, and the Prisma Developer Platform.", "type": "module", "bin": { @@ -62,9 +62,9 @@ "open": "^11.0.0" }, "devDependencies": { - "@prisma/cli": "workspace:8.0.0-rc.8", - "@repo/cli-telemetry": "workspace:8.0.0-rc.8", - "@repo/tsconfig": "workspace:8.0.0-rc.8", + "@prisma/cli": "workspace:8.0.0-rc.9", + "@repo/cli-telemetry": "workspace:8.0.0-rc.9", + "@repo/tsconfig": "workspace:8.0.0-rc.9", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "typescript": "^6.0.3", diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 9ddbed73..3be07542 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,7 +1,7 @@ { "name": "@repo/tsconfig", "private": true, - "version": "8.0.0-rc.8", + "version": "8.0.0-rc.9", "description": "Base tsconfig providing package for the monorepo", "license": "Apache-2.0", "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1bdeb45..56a357c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,13 +64,13 @@ importers: specifier: 0.11.0 version: 0.11.0(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) '@repo/cli-conformance': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../cli-conformance '@repo/cli-telemetry': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../cli-telemetry '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -91,7 +91,7 @@ importers: packages/cli-conformance: devDependencies: '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -134,10 +134,10 @@ importers: version: 8.2.1 devDependencies: '@repo/cli-conformance': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../cli-conformance '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -162,7 +162,7 @@ importers: version: 1.2.4 devDependencies: '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -180,7 +180,7 @@ importers: packages/compute: devDependencies: '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -235,13 +235,13 @@ importers: version: 11.0.0 devDependencies: '@prisma/cli': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../cli '@repo/cli-telemetry': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../cli-telemetry '@repo/tsconfig': - specifier: workspace:8.0.0-rc.8 + specifier: workspace:8.0.0-rc.9 version: link:../tsconfig '@types/node': specifier: ^22.19.19 From dd09c89cd8b971cb54a8ee6c6286260b1fa2f758 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 24 Aug 2026 11:45:24 +0200 Subject: [PATCH 2/3] Three guards make the rc.8 manifest divergence unshippable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class: packages/cli and packages/prisma hand-carry the same runtime dependency list, the prisma bin resolves from the wrapper's copy, and a pin bumped in only one manifest ships a bin running against versions nothing was tested with — prisma@8.0.0-rc.8. 1. packages/cli/tests/manifest-pins.test.ts — every PR: the wrapper's dependencies must deep-equal the shell's. Verified to fail on the planted rc.8 defect. 2. sibling-pin-mismatch, a new tarball-check finding — pack time: any dependency name two packed sibling manifests share must carry the identical specifier. 3. Per-package sandboxes in check 3b — every packed package that declares a bin now installs into its own sandbox and starts there. The rc.8 crash was structurally invisible before: the check installed and started only the shell's bins, never the wrapper's, so the wrapper's resolution was never exercised at all. packages/cli/AGENTS.md carries the two-manifest rule for agents, and the ledger entries close with the guard inventory. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 4 +- .../cli-conformance/src/checks/tarball.ts | 96 ++++++++++--- packages/cli-conformance/src/findings.ts | 4 + .../cli-conformance/tests/tarball.test.ts | 130 ++++++++++++++++++ packages/cli/AGENTS.md | 1 + packages/cli/tests/manifest-pins.test.ts | 35 +++++ 6 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 packages/cli/tests/manifest-pins.test.ts diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 075e795a..4c67dd97 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -474,5 +474,5 @@ The agent-skills project (skills sync/list, `prisma init`, the staleness notice; ## Left open by the rc.8 broken release (2026-08-24) - **`prisma@8.0.0-rc.8` on npm is broken and immutable.** The `prisma` wrapper package carries its own copies of the product pins, and the grammar-cleanup branch bumped only `packages/cli/package.json` — so the published `prisma` bin resolved `@prisma/orm-toolchain@8.0.0-rc.4`, whose old family keys make the mount table's lookups undefined and every invocation crash ("Cannot read properties of undefined (reading 'needs')"). rc.9 fixes it. Consider `npm deprecate prisma@8.0.0-rc.8` (needs a maintainer's npm auth; CI publishes via OIDC and has no deprecate step). -- **The release checks did not catch a `prisma` bin that crashes on install.** "Check the release" passed on the rc.8 run: the conformance sandbox install evidently resolved the good `@prisma/orm-toolchain@8.0.0-rc.5` from `@prisma/cli`'s manifest by hoisting, masking the wrapper's stale rc.4 pin — a real `npm install prisma` hoists rc.4 instead and crashes. Two fixes, both worth doing: a check that the `prisma` and `@prisma/cli` manifests declare identical product pins (one-file script, catches the divergence class statically), and running the tarball check's bin-start against an install of the `prisma` package alone, so resolution happens the way a user's install resolves it. -- **Two manifests hand-carry the same pins.** `update-product-versions.mjs` rewrites both, but a hand-edit (this incident) can touch one. The static pin-equality check above is the guard; deriving one manifest from the other at pack time would remove the class entirely — design call. +- ~~**The release checks did not catch a `prisma` bin that crashes on install.**~~ Closed (2026-08-24, on the rc.9 PR): worse than hoisting — check 3b never installed or started the wrapper's bin at all, only the shell's. Three guards now exist: `packages/cli/tests/manifest-pins.test.ts` (every PR: the wrapper's dependencies must deep-equal the shell's), the tarball check's new `sibling-pin-mismatch` finding (pack time: shared dependency names across packed manifests must carry identical specifiers), and per-package sandboxes in check 3b (every bin-bearing package installs and starts from its own tree). Each guard was proven against the planted rc.8 defect. +- **Two manifests hand-carry the same pins.** `update-product-versions.mjs` rewrites both, and three checks now fail on divergence (see the closed entry above), so the class cannot ship again. Deriving one manifest from the other at pack time would remove the duplication itself — still a design call, no longer urgent. diff --git a/packages/cli-conformance/src/checks/tarball.ts b/packages/cli-conformance/src/checks/tarball.ts index a7fbbde4..99094ced 100644 --- a/packages/cli-conformance/src/checks/tarball.ts +++ b/packages/cli-conformance/src/checks/tarball.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import type { Finding, Suppression } from "../findings"; import { bareImportRoots } from "../module-graph"; import { checkImportPurity, type PackageManifest } from "./import-purity"; @@ -161,11 +162,57 @@ export async function checkTarball( const shell = packed.get(input.shellPackage); if (shell === undefined) return findings; + findings.push(...siblingPinAgreementFindings(input, shell.manifest, packed)); findings.push(...manifestPinFindings(input, shell.manifest)); - findings.push(...(await sandboxFindings(input, shell, packed, io))); + // Every packed package that declares a bin installs into its OWN + // sandbox and starts there, so resolution happens the way that + // package's real install resolves it. prisma@8.0.0-rc.8 shipped a + // wrapper bin that crashed on every invocation while a shell-only + // sandbox stayed green: the wrapper's stale product pin was hoisted + // away by the shell's correct one. + for (const [name, entry] of packed) { + if (declaredBins(entry.manifest).length === 0) continue; + // biome-ignore lint/performance/noAwaitInLoops: sandboxes install one at a time so a failure names its package and concurrent npm installs cannot confound each other + findings.push(...(await sandboxFindings(input, name, entry, packed, io))); + } return applyExceptions(findings, input.exceptions); } +/** + * The rc.8 guard: sibling packages that ship the same bundled source — + * the shell and the `prisma` wrapper — hand-carry their dependency + * lists in separate manifests, and which copy of a dependency a user's + * install resolves depends on hoisting. Any dependency name two packed + * manifests share must therefore carry the identical specifier. + */ +function siblingPinAgreementFindings( + input: TarballInput, + shellManifest: PackedManifest, + packed: ReadonlyMap, +): readonly Finding[] { + const findings: Finding[] = []; + const shellDeps = shellManifest.dependencies ?? {}; + for (const [name, entry] of packed) { + if (name === input.shellPackage) continue; + for (const [dep, specifier] of Object.entries( + entry.manifest.dependencies ?? {}, + )) { + const shellSpecifier = shellDeps[dep]; + if (shellSpecifier === undefined || shellSpecifier === specifier) { + continue; + } + findings.push( + finding( + "sibling-pin-mismatch", + name, + `${name} pins ${dep}@${specifier} while ${input.shellPackage} pins ${shellSpecifier} — which one an install resolves depends on hoisting`, + ), + ); + } + } + return findings; +} + /** * 3c, sibling leg: every packed manifest that depends on the engine * must pin exactly the engine version packed beside it. This is how @@ -249,13 +296,18 @@ function manifestPinFindings( return findings; } -/** 3b + 3c's installed legs, all downstream of one sandbox install. */ +/** 3b + 3c's installed legs, one sandbox per bin-bearing package. */ async function sandboxFindings( input: TarballInput, - shell: { tarball: string; manifest: PackedManifest }, + packageName: string, + root: { tarball: string; manifest: PackedManifest }, packed: ReadonlyMap, io: TarballIo, ): Promise { + const sandboxDir = join( + input.sandboxDir, + packageName.replace(/[@/]/g, "-").replace(/^-/, ""), + ); // Transitive: a sibling reached only through another sibling still // needs its override, or the install falls back to the registry. const overrides: Record = {}; @@ -269,38 +321,40 @@ async function sandboxFindings( visit(entry.manifest); } }; - visit(shell.manifest); + visit(root.manifest); const install = await io.installSandbox({ - sandboxDir: input.sandboxDir, - rootTarball: shell.tarball, + sandboxDir, + rootTarball: root.tarball, overrides, }); if (!install.ok) { return [ finding( "install-failed", - input.shellPackage, + packageName, "the packed tarball did not install into a clean tree", install.output, ), ]; } return [ - ...(await binFindings(input, shell.manifest, io)), - ...(await installedPinFindings(input, shell.manifest, io)), + ...(await binFindings(input, packageName, sandboxDir, root.manifest, io)), + ...(await installedPinFindings(input, sandboxDir, root.manifest, io)), ]; } async function binFindings( input: TarballInput, - shellManifest: PackedManifest, + packageName: string, + sandboxDir: string, + manifest: PackedManifest, io: TarballIo, ): Promise { const findings: Finding[] = []; - for (const [binName, relPath] of declaredBins(shellManifest)) { + for (const [binName, relPath] of declaredBins(manifest)) { // biome-ignore lint/performance/noAwaitInLoops: bins start one at a time so a failure names its bin and concurrent processes cannot confound each other's exit const run = await io.startBin({ - sandboxDir: input.sandboxDir, + sandboxDir, binName, relPath, argv: ["--version"], @@ -310,7 +364,7 @@ async function binFindings( findings.push( finding( "bin-failed", - input.shellPackage, + packageName, `bin ${binName} timed out instead of exiting`, run.stderr, ), @@ -319,7 +373,7 @@ async function binFindings( findings.push( finding( "bin-failed", - input.shellPackage, + packageName, `bin ${binName} exited ${run.exitCode} on plain node`, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`, ), @@ -376,31 +430,29 @@ function familyPinFindings( async function installedPinFindings( input: TarballInput, - shellManifest: PackedManifest, + sandboxDir: string, + manifest: PackedManifest, io: TarballIo, ): Promise { const findings: Finding[] = []; - const shellPin = shellManifest.dependencies?.[input.enginePackage]; + const shellPin = manifest.dependencies?.[input.enginePackage]; for (const family of input.familyPackages) { // biome-ignore lint/performance/noAwaitInLoops: one manifest read per mounted family — two today — keeps findings ordered with the family list - const installed = await io.readInstalledManifest(input.sandboxDir, family); + const installed = await io.readInstalledManifest(sandboxDir, family); if (installed === undefined) continue; findings.push( ...familyPinFindings( input, family, - shellManifest.dependencies?.[family], + manifest.dependencies?.[family], installed, shellPin, ), ); } - const copies = await io.listInstalledCopies( - input.sandboxDir, - input.enginePackage, - ); + const copies = await io.listInstalledCopies(sandboxDir, input.enginePackage); if (copies.length > 1) { findings.push( finding( diff --git a/packages/cli-conformance/src/findings.ts b/packages/cli-conformance/src/findings.ts index 7e5b0745..9211b51e 100644 --- a/packages/cli-conformance/src/findings.ts +++ b/packages/cli-conformance/src/findings.ts @@ -30,6 +30,10 @@ export type FindingKind = | "bin-failed" /** The shell and a family it mounts disagree about the engine version. */ | "engine-pin-mismatch" + /** Two packed sibling manifests declare the same dependency at + * different versions, so which one an install resolves depends on + * hoisting. */ + | "sibling-pin-mismatch" /** A release depends on a dev build. */ | "dev-build-in-release"; diff --git a/packages/cli-conformance/tests/tarball.test.ts b/packages/cli-conformance/tests/tarball.test.ts index c921d1aa..f12ca6cf 100644 --- a/packages/cli-conformance/tests/tarball.test.ts +++ b/packages/cli-conformance/tests/tarball.test.ts @@ -600,3 +600,133 @@ describe("checkTarball", () => { ).toBe(true); }); }); + +describe("sibling manifests and per-package sandboxes (the rc.8 class)", () => { + const WRAPPER_MANIFEST: PackageManifest & { bin?: Record } = { + bin: { prisma: "./dist/prisma.js" }, + dependencies: { + "@prisma/cli-engine": "8.0.0-rc.1", + "@prisma/composer": "0.6.0-dev.16", + colorette: "^2.0.20", + }, + }; + + function wrapperIo( + wrapperManifest: PackageManifest, + overrides: Partial = {}, + ): TarballIo { + return fakeIo({ + readPackedManifest: (tarball) => + Promise.resolve( + tarball.includes("cli-engine") + ? ENGINE_MANIFEST + : tarball.includes("prisma-wrapper") + ? wrapperManifest + : SHELL_MANIFEST, + ), + readPackedFiles: (tarball) => + Promise.resolve( + tarball.includes("prisma-wrapper") + ? new Map([ + [ + "dist/prisma.js", + 'import "colorette";\nimport "@prisma/cli-engine";\nimport "@prisma/composer/family";\n', + ], + ]) + : new Map(), + ), + ...overrides, + }); + } + + const wrapperInput = () => + input({ + packages: [ + { name: "@prisma/cli", dir: "packages/cli" }, + { name: "prisma", dir: "packages/prisma-wrapper" }, + { name: "@prisma/cli-engine", dir: "packages/cli-engine" }, + ], + }); + + test("a sibling pinning a shared dependency at another version is a finding", async () => { + const findings = await checkTarball( + wrapperInput(), + wrapperIo({ + ...WRAPPER_MANIFEST, + // The rc.8 defect verbatim: the wrapper's product pin lags the + // shell's, and hoisting decides which one a user's bin runs. + dependencies: { + ...WRAPPER_MANIFEST.dependencies, + "@prisma/composer": "0.5.0", + }, + }), + ); + const mismatch = findings.filter((f) => f.kind === "sibling-pin-mismatch"); + expect(mismatch).toHaveLength(1); + expect(mismatch[0]?.subject).toBe("prisma"); + expect(mismatch[0]?.summary).toContain("@prisma/composer@0.5.0"); + }); + + test("agreeing siblings raise no sibling finding", async () => { + const findings = await checkTarball( + wrapperInput(), + wrapperIo(WRAPPER_MANIFEST), + ); + expect(findings.filter((f) => f.kind === "sibling-pin-mismatch")).toEqual( + [], + ); + }); + + test("every bin-bearing package installs and starts in its own sandbox", async () => { + const started: string[] = []; + const installed: string[] = []; + await checkTarball( + wrapperInput(), + wrapperIo(WRAPPER_MANIFEST, { + installSandbox: ({ sandboxDir }) => { + installed.push(sandboxDir); + return Promise.resolve({ ok: true as const }); + }, + startBin: ({ sandboxDir, binName }) => { + started.push(`${sandboxDir}:${binName}`); + return Promise.resolve({ + exitCode: 0, + stdout: "", + stderr: "", + timedOut: false, + }); + }, + }), + ); + // Two sandboxes — the engine has no bin — and each bin starts in + // its package's own sandbox, never the other's. + expect(new Set(installed).size).toBe(2); + expect(started.some((s) => s.endsWith("prisma-cli"))).toBe(true); + expect(started.some((s) => s.endsWith(":prisma"))).toBe(true); + const dirs = new Set(started.map((s) => s.split(":")[0])); + expect(dirs.size).toBe(2); + }); + + test("a wrapper bin that fails in its own sandbox is a finding naming the wrapper", async () => { + const findings = await checkTarball( + wrapperInput(), + wrapperIo(WRAPPER_MANIFEST, { + startBin: ({ binName }) => + Promise.resolve( + binName === "prisma" + ? { + exitCode: 1, + stdout: "", + stderr: + "Cannot read properties of undefined (reading 'needs')", + timedOut: false, + } + : { exitCode: 0, stdout: "", stderr: "", timedOut: false }, + ), + }), + ); + const failed = findings.filter((f) => f.kind === "bin-failed"); + expect(failed).toHaveLength(1); + expect(failed[0]?.subject).toBe("prisma"); + }); +}); diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 4f762f9c..80934c19 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -93,6 +93,7 @@ Important themes: - Do not add shortcuts or aliases as canonical forms. - Do not let the current app preview introduce abstractions that will block later ORM/Postgres integration. - If docs conflict, resolve the docs rather than guessing in implementation. +- Dependency pins live in TWO manifests: `packages/cli/package.json` and `packages/prisma/package.json` declare the same runtime dependencies, and the `prisma` bin resolves from the wrapper's copy. Change them together, always. `tests/manifest-pins.test.ts` and the tarball conformance check both fail on divergence — this rule exists because `prisma@8.0.0-rc.8` shipped crashing after a pin was bumped in only one of them. ## Default Agent Workflow diff --git a/packages/cli/tests/manifest-pins.test.ts b/packages/cli/tests/manifest-pins.test.ts new file mode 100644 index 00000000..04ce0d4e --- /dev/null +++ b/packages/cli/tests/manifest-pins.test.ts @@ -0,0 +1,35 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const CLI_DIR = fileURLToPath(new URL("..", import.meta.url)); + +async function dependencies(dir: string): Promise> { + const manifest = JSON.parse( + await readFile(join(dir, "package.json"), "utf8"), + ) as { dependencies?: Record }; + return manifest.dependencies ?? {}; +} + +/** + * The `prisma` wrapper bundles the same bin as `@prisma/cli`, so both + * manifests must declare the SAME runtime dependencies at the SAME + * versions. They are hand-carried in two files, and `npm install + * prisma` resolves from the wrapper's copy — a divergence ships a bin + * running against versions nothing was tested with. + * + * prisma@8.0.0-rc.8 is the incident this guards against: a product pin + * was bumped only in packages/cli, the published wrapper resolved the + * old @prisma/orm-toolchain, its old family keys made the mount table's + * lookups undefined, and every invocation crashed. The conformance + * sandbox masked it by hoisting the good version from @prisma/cli's + * manifest, which a real install of `prisma` alone does not do. + */ +describe("the prisma wrapper's manifest", () => { + it("declares exactly @prisma/cli's runtime dependencies", async () => { + const cli = await dependencies(CLI_DIR); + const wrapper = await dependencies(join(CLI_DIR, "..", "prisma")); + expect(wrapper).toEqual(cli); + }); +}); From 7ec57708cbb21a3b27a00ff03ccbf3a3dec84ea3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 24 Aug 2026 11:53:42 +0200 Subject: [PATCH 3/3] The tarball IO honors the sandboxDir it is handed Per-package sandboxes exposed a latent bug: realTarballIo ignored the sandboxDir argument, always installing into one fixed directory and reading the started package's name back from it. With two sandboxes, startBin looked for node_modules in directories nothing had installed into, and both bins reported exit null. installSandbox now creates the directory it was asked for (fresh each time) and startBin reads the manifest from the same directory it starts in. Also hoists the sandbox name regexes and de-nests a test ternary, per lint. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-conformance/src/checks/tarball.ts | 6 +++++- packages/cli-conformance/src/tarball-io.ts | 8 ++++---- packages/cli-conformance/tests/tarball.test.ts | 17 +++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cli-conformance/src/checks/tarball.ts b/packages/cli-conformance/src/checks/tarball.ts index 99094ced..ffdfa48a 100644 --- a/packages/cli-conformance/src/checks/tarball.ts +++ b/packages/cli-conformance/src/checks/tarball.ts @@ -106,6 +106,10 @@ export interface TarballInput { const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +/** Package-name characters that cannot appear in a directory name. */ +const SANDBOX_NAME_UNSAFE = /[@/]/g; +const LEADING_DASH = /^-/; + /** * Check 3: the tarballs a registry would receive. 3a — packed output * imports only what the packed manifest declares. 3b — the root tarball @@ -306,7 +310,7 @@ async function sandboxFindings( ): Promise { const sandboxDir = join( input.sandboxDir, - packageName.replace(/[@/]/g, "-").replace(/^-/, ""), + packageName.replace(SANDBOX_NAME_UNSAFE, "-").replace(LEADING_DASH, ""), ); // Transitive: a sibling reached only through another sibling still // needs its override, or the install falls back to the registry. diff --git a/packages/cli-conformance/src/tarball-io.ts b/packages/cli-conformance/src/tarball-io.ts index fe600d3a..7f97472c 100644 --- a/packages/cli-conformance/src/tarball-io.ts +++ b/packages/cli-conformance/src/tarball-io.ts @@ -38,7 +38,6 @@ export function realTarballIo( // exact files CI uploads and attaches to the GitHub Release. const tarballDir = resolve(options.tarballDir ?? join(absWork, "tarballs")); rmSync(tarballDir, { recursive: true, force: true }); - const sandbox = () => join(absWork, "sandbox"); return { async pack(pkgDir) { @@ -88,8 +87,9 @@ export function realTarballIo( return files; }, - async installSandbox({ rootTarball, overrides }) { - const dir = sandbox(); + async installSandbox({ sandboxDir, rootTarball, overrides }) { + const dir = resolve(sandboxDir); + rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); const rootManifest = await this.readPackedManifest(rootTarball); const name = manifestName(rootManifest); @@ -176,7 +176,7 @@ export function realTarballIo( argv, timeoutMs, }) { - const rootManifestPath = join(sandbox(), "package.json"); + const rootManifestPath = join(sandboxDir, "package.json"); const rootManifest = JSON.parse( readFileSync(rootManifestPath, "utf8"), ) as { diff --git a/packages/cli-conformance/tests/tarball.test.ts b/packages/cli-conformance/tests/tarball.test.ts index f12ca6cf..e805b26c 100644 --- a/packages/cli-conformance/tests/tarball.test.ts +++ b/packages/cli-conformance/tests/tarball.test.ts @@ -616,14 +616,15 @@ describe("sibling manifests and per-package sandboxes (the rc.8 class)", () => { overrides: Partial = {}, ): TarballIo { return fakeIo({ - readPackedManifest: (tarball) => - Promise.resolve( - tarball.includes("cli-engine") - ? ENGINE_MANIFEST - : tarball.includes("prisma-wrapper") - ? wrapperManifest - : SHELL_MANIFEST, - ), + readPackedManifest: (tarball) => { + if (tarball.includes("cli-engine")) { + return Promise.resolve(ENGINE_MANIFEST); + } + if (tarball.includes("prisma-wrapper")) { + return Promise.resolve(wrapperManifest); + } + return Promise.resolve(SHELL_MANIFEST); + }, readPackedFiles: (tarball) => Promise.resolve( tarball.includes("prisma-wrapper")