From cd42e3feb3e4bd50d178a7b5dfbbcdbd6777b047 Mon Sep 17 00:00:00 2001 From: brbousnguar Date: Sat, 12 Sep 2026 08:02:14 +0200 Subject: [PATCH 1/2] fix(cli): let orm init complete under pnpm 11 and 12 pnpm 11 and later fail an `add` whose dependencies have build scripts nobody approved (ERR_PNPM_IGNORED_BUILDS), after the packages are already added and linked. `orm init` treated that exit code as a failed install and stopped before the dev dependencies and the contract emit. A pnpm failure now counts as installed, with a warning pointing at `pnpm approve-builds`, when stderr names ERR_PNPM_IGNORED_BUILDS (pnpm 12) or when package.json changed across the call (pnpm 11 prints the error on stdout, which the package-manager capability does not return). pnpm leaves package.json untouched when resolution, a fetch or an approved build script fails, so those still fail the install. pnpm 12 also links a `@prisma/cli-engine` added in its own `pnpm add` to a store entry it never materializes, which broke the emit with CONFIG_UNREADABLE. The engine now goes into the same `add -D` as `prisma`, pinned to the exact version the installed runtime's @prisma/orm-toolchain peer-depends on. When the emit fails, init now reports the error from the child's result envelope on stdout instead of whatever stderr held, which was the agent-skills reminder and hid the real error. Signed-off-by: brbousnguar --- .../3-tooling/cli/src/orm/init-emit.ts | 31 ++- .../3-tooling/cli/src/orm/init-packages.ts | 195 +++++++++++++----- .../1-framework/3-tooling/cli/src/orm/init.ts | 31 +-- .../3-tooling/cli/test/orm/init-emit.test.ts | 37 ++++ .../cli/test/orm/init-install.test.ts | 155 +++++++++++--- .../cli/test/orm/init-scaffold.test.ts | 4 +- 6 files changed, 352 insertions(+), 101 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/init-emit.ts b/packages/1-framework/3-tooling/cli/src/orm/init-emit.ts index 434c3299b883..dfcf379c0a8f 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init-emit.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init-emit.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { type } from 'arktype'; import { dirname, join } from 'pathe'; import { redactSecrets } from '../commands/init/redact-secrets'; @@ -33,7 +34,9 @@ export async function emitScaffoldedContract( const binPath = resolveProjectBin(ctx.cwd, overrides); const result = await runCaptured(process.execPath, [binPath, 'contract', 'emit'], ctx.cwd); if (result.exitCode !== 0) { - const output = result.stderr.trim().length > 0 ? result.stderr : result.stdout; + const output = + envelopeError(result.stdout) ?? + (result.stderr.trim().length > 0 ? result.stderr : result.stdout); const cause = result.exitCode === null ? `was killed by signal ${result.signal ?? 'unknown'}` @@ -108,6 +111,32 @@ function runCaptured( }); } +const FailedResultLine = type({ + kind: "'result'", + envelope: { error: { code: 'string', summary: 'string' } }, +}); + +/** + * With its output captured, the child writes its terminal result envelope on + * stdout, so a failed run names its error there — while stderr can hold + * nothing but an unrelated notice, such as the agent-skills reminder. + */ +function envelopeError(stdout: string): string | undefined { + for (const line of stdout.trim().split('\n').reverse()) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const result = FailedResultLine(parsed); + if (!(result instanceof type.errors)) { + return `${result.envelope.error.code}: ${result.envelope.error.summary}`; + } + } + return undefined; +} + function tail(text: string): string { return text.trim().split('\n').slice(-STDERR_TAIL_LINES).join('\n'); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts b/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts index 9b7b00292fcb..39c221f9a8a1 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts @@ -1,29 +1,79 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { ifDefined } from '@internal/utils/defined'; import type { PackageManagerId, PackageOperations } from '@prisma/cli-engine'; import type { CliStructuredError } from '@prisma/cli-engine/protocol'; -import { join } from 'pathe'; +import { dirname, join } from 'pathe'; import { isRecognisedPnpmResolutionError } from '../commands/init/pnpm-fallback'; import { redactSecrets } from '../commands/init/redact-secrets'; -/** What one install pair produced, and which manager finished it. */ +const ENGINE_PACKAGE = '@prisma/cli-engine'; +const TOOLCHAIN_PACKAGE = '@prisma/orm-toolchain'; + +/** What one install pair produced. */ export interface InstallOutcome { /** Absent on success; the capability's own failure otherwise. */ readonly failure: CliStructuredError | undefined; - /** - * The manager override that succeeded, when the pnpm fallback fired. The - * follow-up engine install reuses it: driving pnpm right after `pnpm add` - * failed to resolve a workspace specifier would fail the same way. - */ - readonly manager: PackageManagerId | undefined; + /** The development dependencies the pair installed, the engine spec included. */ + readonly devDeps: readonly string[]; readonly warnings: readonly string[]; } +type InstallRequest = Parameters[0]; + +interface StepResult { + readonly failure: CliStructuredError | undefined; + /** The pnpm failure let through because only unapproved build scripts were skipped. */ + readonly skippedBuilds: CliStructuredError | undefined; +} + +interface PairResult extends StepResult { + readonly devDeps: readonly string[]; +} + function metaString(failure: CliStructuredError, key: string): string { const value = failure.meta?.[key]; return typeof value === 'string' ? value : ''; } +function readManifest(cwd: string): string | undefined { + try { + return readFileSync(join(cwd, 'package.json'), 'utf-8'); + } catch { + return undefined; + } +} + +/** + * pnpm 11 and later fail an `add` whose dependencies carry build scripts + * nobody approved (`ERR_PNPM_IGNORED_BUILDS`) — after the packages are added + * and linked, so the scripts are all that did not run. pnpm 12 names the code + * on stderr. pnpm 11 prints it on stdout, which the capability does not hand + * back, so the other tell is the manifest: pnpm rewrites `package.json` only + * once the add has landed, and leaves it untouched when resolution, a fetch, + * or an approved build script fails. + */ +function pnpmOnlySkippedBuilds( + failure: CliStructuredError, + manifestBefore: string | undefined, + cwd: string, +): boolean { + return ( + metaString(failure, 'manager') === 'pnpm' && + (metaString(failure, 'stderrTail').includes('ERR_PNPM_IGNORED_BUILDS') || + readManifest(cwd) !== manifestBefore) + ); +} + +function skippedBuildsWarning(failure: CliStructuredError): string { + const exitCode = failure.meta?.['exitCode']; + const exited = typeof exitCode === 'number' ? `exited with code ${exitCode}` : 'exited non-zero'; + return [ + `pnpm ${exited} after adding the packages, which is how pnpm 11 and later report dependency build scripts it has not been told to trust (ERR_PNPM_IGNORED_BUILDS).`, + 'The packages are installed but those scripts did not run; init continued without them.', + 'Run `pnpm approve-builds` to review which dependencies may run their scripts.', + ].join('\n'); +} + /** * pnpm reported a specifier the published artifact leaked, which npm installs * happily. The engine redacts credentials out of the stderr it returns but @@ -65,84 +115,121 @@ function retriedWarning(failure: CliStructuredError): string { .join('\n'); } -/** - * Adds the runtime and development dependencies through the engine's package - * manager. The retry is `init`'s alone: the engine spells and runs the - * command, and this decides — from the stderr it returned — that another - * manager is worth a try. - */ +function enginePeerOf(manifestPath: string): string | undefined { + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + } catch { + return undefined; + } + const peers = + typeof manifest === 'object' && manifest !== null + ? Reflect.get(manifest, 'peerDependencies') + : undefined; + const engine = + typeof peers === 'object' && peers !== null ? Reflect.get(peers, ENGINE_PACKAGE) : undefined; + return typeof engine === 'string' && engine.length > 0 ? engine : undefined; +} + /** * The engine dependency spec a fresh scaffold installs. The scaffolded - * `prisma.config.ts` imports `defineConfig` from `@prisma/cli-engine`, and the - * installed `prisma` names the exact engine version it runs against — so - * the spec is read from the manifest the install just placed, never guessed - * from a dist-tag (whose `latest` has lagged that version before and broken - * the very next command). A CLI without a readable engine entry falls back to - * the `next` tag, the release train the CLI itself publishes under. + * `prisma.config.ts` imports `definePrismaConfig` from `@prisma/cli-engine`, + * and the runtime's `@prisma/orm-toolchain` peer-depends on the exact engine + * version it runs against — so the spec is read from the manifest the runtime + * install just placed, never guessed from a dist-tag (whose `latest` has + * lagged that version before and broken the very next command). It has to be + * known before `prisma` installs, because the engine goes into the same `add` + * as `prisma`: pnpm 12 links an engine added on its own to a store entry it + * never materializes. The toolchain sits either under the runtime or beside + * it — in pnpm's virtual store, beside the link's target. A runtime without a + * readable toolchain manifest falls back to the `latest` tag. */ -export function engineDevDependencySpec(cwd: string): string { +function engineDevDependencySpec(cwd: string, runtimePackage: string): string { + let runtimeDir: string; try { - const manifest: unknown = JSON.parse( - readFileSync(join(cwd, 'node_modules', 'prisma', 'package.json'), 'utf-8'), - ); - if (typeof manifest === 'object' && manifest !== null) { - for (const field of ['dependencies', 'peerDependencies'] as const) { - const block = Reflect.get(manifest, field); - if (typeof block === 'object' && block !== null) { - const engine = Reflect.get(block, '@prisma/cli-engine'); - if (typeof engine === 'string' && engine.length > 0) { - return `@prisma/cli-engine@${engine}`; - } - } - } - } + runtimeDir = realpathSync(join(cwd, 'node_modules', runtimePackage)); } catch { - // The fallback below covers an unreadable or unparseable manifest. + return `${ENGINE_PACKAGE}@latest`; } - return '@prisma/cli-engine@latest'; + const modulesDir = runtimePackage.split('/').reduce((dir) => dirname(dir), runtimeDir); + const engine = + enginePeerOf(join(runtimeDir, 'node_modules', TOOLCHAIN_PACKAGE, 'package.json')) ?? + enginePeerOf(join(modulesDir, TOOLCHAIN_PACKAGE, 'package.json')); + return `${ENGINE_PACKAGE}@${engine ?? 'latest'}`; } +/** + * Adds the runtime and development dependencies through the engine's package + * manager. The retry is `init`'s alone: the engine spells and runs the + * command, and this decides — from the stderr it returned — that another + * manager is worth a try, or that a pnpm add which skipped only unapproved + * build scripts did install. + */ export async function installProjectDependencies(ctx: { readonly packages: PackageOperations; readonly cwd: string; readonly deps: readonly string[]; + readonly runtimePackage: string; readonly devDeps: readonly string[]; readonly catalogWarnings: readonly string[]; }): Promise { - const pair = async (manager?: PackageManagerId): Promise => { - const runtimeDeps = await ctx.packages.install({ + const install = async (request: InstallRequest): Promise => { + const manifestBefore = readManifest(ctx.cwd); + const result = await ctx.packages.install(request); + if (result.ok) { + return { failure: undefined, skippedBuilds: undefined }; + } + return pnpmOnlySkippedBuilds(result.failure, manifestBefore, ctx.cwd) + ? { failure: undefined, skippedBuilds: result.failure } + : { failure: result.failure, skippedBuilds: undefined }; + }; + + const pair = async (manager?: PackageManagerId): Promise => { + const runtimeDeps = await install({ packages: ctx.deps, cwd: ctx.cwd, ...ifDefined('manager', manager), }); - if (!runtimeDeps.ok) { - return runtimeDeps.failure; + if (runtimeDeps.failure !== undefined) { + return { ...runtimeDeps, devDeps: [] }; } - const developmentDeps = await ctx.packages.install({ - packages: ctx.devDeps, + const devDeps = [...ctx.devDeps, engineDevDependencySpec(ctx.cwd, ctx.runtimePackage)]; + const developmentDeps = await install({ + packages: devDeps, dev: true, cwd: ctx.cwd, ...ifDefined('manager', manager), }); - return developmentDeps.ok ? undefined : developmentDeps.failure; + return { + failure: developmentDeps.failure, + skippedBuilds: developmentDeps.skippedBuilds ?? runtimeDeps.skippedBuilds, + devDeps, + }; }; - const failure = await pair(); - if (failure === undefined) { - return { failure: undefined, manager: undefined, warnings: ctx.catalogWarnings }; + const first = await pair(); + if (first.failure === undefined) { + return { + failure: undefined, + devDeps: first.devDeps, + warnings: + first.skippedBuilds === undefined + ? ctx.catalogWarnings + : [...ctx.catalogWarnings, skippedBuildsWarning(first.skippedBuilds)], + }; } - if (!pnpmLeakedASpecifier(failure)) { - return { failure, manager: undefined, warnings: [] }; + if (!pnpmLeakedASpecifier(first.failure)) { + return { failure: first.failure, devDeps: [], warnings: [] }; } - const retryFailure = await pair('npm'); - if (retryFailure !== undefined) { + const retry = await pair('npm'); + if (retry.failure !== undefined) { // The npm failure is the one raised, but the pnpm failure that triggered // the retry is why npm ran at all — without it the user sees an npm error // with no trace of the first attempt. - return { failure: retryFailure, manager: undefined, warnings: [retriedWarning(failure)] }; + return { failure: retry.failure, devDeps: [], warnings: [retriedWarning(first.failure)] }; } // npm bypassed pnpm's resolver, so the workspace catalog is not what ended // up installed — saying otherwise alongside the fallback would contradict it. - return { failure: undefined, manager: 'npm', warnings: [fallbackWarning(failure)] }; + return { failure: undefined, devDeps: retry.devDeps, warnings: [fallbackWarning(first.failure)] }; } diff --git a/packages/1-framework/3-tooling/cli/src/orm/init.ts b/packages/1-framework/3-tooling/cli/src/orm/init.ts index 502427d94326..6c8b5a78b32c 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init.ts @@ -1,4 +1,3 @@ -import { ifDefined } from '@internal/utils/defined'; import { docsUrlFor } from '@internal/utils/structured-error'; import { flag } from '@prisma/cli-engine'; import type { Diagnostic, NextAction } from '@prisma/cli-engine/protocol'; @@ -21,7 +20,7 @@ import { buildInitNextActions, initPresentations } from './init-blocks'; import { EMIT_COMMAND, emitFailedFinding, installFailedFinding } from './init-diagnostics'; import { emitScaffoldedContract } from './init-emit'; import { resolveInitInputs } from './init-inputs'; -import { engineDevDependencySpec, installProjectDependencies } from './init-packages'; +import { installProjectDependencies } from './init-packages'; import { resolveScaffoldPackageManager, scaffoldProject } from './init-scaffold'; import { normalizeError } from './normalize-error'; @@ -135,20 +134,21 @@ export const createInitCommand = (injected: InitCommandDependencies) => ctx.report({ kind: 'message', severity: 'info', text: note }); } - const deps = [targetPackageName(inputs.target, scaffold.resolveImportSpecifier), 'dotenv']; + const runtimePackage = targetPackageName(inputs.target, scaffold.resolveImportSpecifier); + const deps = [runtimePackage, 'dotenv']; // The CLI the scaffolded scripts run is `prisma`, the unified CLI's // published name, whose v8 line publishes under the `latest` dist-tag (the // standalone shim is no longer published). It is the package that // carries the `prisma` binary, which is what the scaffolded scripts // invoke. `@prisma/cli-engine` — the config file's - // defineConfig import — is deliberately absent here: the CLI declares it - // as an exact peer, so it installs in a second step at the version the - // just-installed CLI names. Under moduleResolution 'bundler' the + // definePrismaConfig import — is added by the install itself, in the + // same `add` as `prisma`, at the exact version the just-installed + // runtime's toolchain peers on. Under moduleResolution 'bundler' the // scaffolded files reference process.env, which only typechecks with // Node's ambient types present; a project that already pins @types/node // keeps its own major. const cliDevDeps = ['prisma@latest']; - const devDeps: string[] = scaffold.hasTypesNode ? cliDevDeps : [...cliDevDeps, '@types/node']; + let devDeps: string[] = scaffold.hasTypesNode ? cliDevDeps : [...cliDevDeps, '@types/node']; const findings: Diagnostic[] = []; const extraActions: NextAction[] = []; @@ -218,6 +218,7 @@ export const createInitCommand = (injected: InitCommandDependencies) => packages: ctx.packages, cwd: ctx.cwd, deps, + runtimePackage, devDeps, catalogWarnings: packageManager === 'pnpm' ? buildCatalogWarnings(ctx.cwd, [...deps, ...devDeps]) : [], @@ -230,19 +231,7 @@ export const createInitCommand = (injected: InitCommandDependencies) => findings.push(installFailedFinding(outcome.failure, scaffold.filesWritten)); return settle(4); } - const engineSpec = engineDevDependencySpec(ctx.cwd); - const engineInstall = await ctx.packages.install({ - packages: [engineSpec], - dev: true, - cwd: ctx.cwd, - ...ifDefined('manager', outcome.manager), - }); - if (!engineInstall.ok) { - packagesInstalled = 'failed'; - findings.push(installFailedFinding(engineInstall.failure, scaffold.filesWritten)); - return settle(4); - } - devDeps.push(engineSpec); + devDeps = [...outcome.devDeps]; packagesInstalled = 'installed'; const emitStep = 'Emit the contract'; @@ -259,7 +248,7 @@ export const createInitCommand = (injected: InitCommandDependencies) => } else { extraActions.push( chooseAction( - `Install the project dependencies with your package manager: ${deps.join(', ')} (and ${devDeps.join(', ')} plus @prisma/cli-engine at the version prisma declares as its dependency, as development dependencies)`, + `Install the project dependencies with your package manager: ${deps.join(', ')} (and ${devDeps.join(', ')} plus @prisma/cli-engine at the version prisma declares as its dependency, as development dependencies in a single install)`, ), ); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts index 7e2e8c674716..80a19589bee4 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts @@ -117,6 +117,43 @@ describe('emitScaffoldedContract', () => { timeouts.databaseOperation, ); + it( + 'reports the error from the result envelope on stdout, not a notice on stderr', + async () => { + const lines = [ + { kind: 'step-started', step: 'Resolving contract source...' }, + { + kind: 'result', + envelope: { + ok: false, + commandId: 'contract.emit', + error: { + code: 'CLI.CONFIG_UNREADABLE', + severity: 'error', + summary: + "prisma.config.ts could not be evaluated: Cannot find module '@prisma/cli-engine'", + }, + }, + }, + ]; + installFakePrismaCli( + [ + `for (const line of ${JSON.stringify(lines)}) process.stdout.write(JSON.stringify(line) + '\\n');`, + "process.stderr.write('Prisma agent skills are out of date (installed @prisma/orm-postgres 8.0.0-rc.9, synced none). Run: prisma skills sync\\n');", + 'process.exit(2);', + '', + ].join('\n'), + ); + + const error = await emitFailure(); + + expect(error.message).toBe( + "`prisma contract emit` exited with code 2: CLI.CONFIG_UNREADABLE: prisma.config.ts could not be evaluated: Cannot find module '@prisma/cli-engine'", + ); + }, + timeouts.databaseOperation, + ); + it( 'accepts a string-form bin field', async () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts index 7bfa76443c7e..9dd2c3e1bd3b 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import type { MountedTree, PackageManagerId, PackageManagerRunner } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; import { timeouts } from '@repo/test-utils'; @@ -26,6 +26,7 @@ interface RunnerCall { interface ScriptedResult { readonly exitCode: number; readonly stderr: string; + readonly addsToManifest?: boolean; } let projectDir: string; @@ -35,6 +36,17 @@ let script: ScriptedResult[]; const PNPM_WORKSPACE_LEAK = 'ERR_PNPM_WORKSPACE_PKG_NOT_FOUND In : "@prisma/orm-postgres@workspace:*" is in the dependencies but no package named "@prisma/orm-postgres" is present in the workspace'; +/** What pnpm 12 writes to stderr when strictDepBuilds (on by default) stops an add. */ +const PNPM_12_IGNORED_BUILDS = [ + 'Error: ERR_PNPM_IGNORED_BUILDS', + '', + ' × adding a new package', + ' ╰─▶ Ignored build scripts: esbuild@0.28.2, msgpackr-extract@3.0.4,', + ' workerd@1.20260704.1', + ' help: Run "pnpm approve-builds" to pick which dependencies should be allowed', + ' to run scripts.', +].join('\n'); + beforeEach(() => { projectDir = createTestProjectDir('orm-init-install'); calls = []; @@ -46,9 +58,27 @@ afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); }); +/** What pnpm has already written by the time it fails an add over ignored build scripts. */ +function addToManifest(cwd: string, args: readonly string[]): void { + const manifestPath = join(cwd, 'package.json'); + const manifest: Record = existsSync(manifestPath) + ? JSON.parse(readFileSync(manifestPath, 'utf-8')) + : {}; + const field = args.includes('-D') ? 'devDependencies' : 'dependencies'; + const added = args.filter((arg) => arg !== 'add' && arg !== '-D'); + writeFileSync( + manifestPath, + JSON.stringify({ ...manifest, [field]: Object.fromEntries(added.map((spec) => [spec, '*'])) }), + ); +} + const runner: PackageManagerRunner = async (request) => { calls.push({ file: request.file, args: [...request.args], cwd: request.cwd }); - return script.shift() ?? { exitCode: 0, stderr: '' }; + const result = script.shift() ?? { exitCode: 0, stderr: '' }; + if (result.addsToManifest === true) { + addToManifest(request.cwd, request.args); + } + return { exitCode: result.exitCode, stderr: result.stderr }; }; function harness(packageManager?: PackageManagerId) { @@ -77,28 +107,34 @@ function skillCalls(): readonly RunnerCall[] { describe('init installs', () => { it( - 'pins the engine to the exact version the installed prisma package declares', + 'installs the engine in the same add as prisma, pinned to the version the runtime toolchain peers on', async () => { - const cliManifestDir = join(projectDir, 'node_modules', 'prisma'); - mkdirSync(cliManifestDir, { recursive: true }); + const store = join(projectDir, 'node_modules', '.pnpm', 'runtime', 'node_modules', '@prisma'); + mkdirSync(join(store, 'orm-postgres'), { recursive: true }); + mkdirSync(join(store, 'orm-toolchain'), { recursive: true }); writeFileSync( - join(cliManifestDir, 'package.json'), + join(store, 'orm-toolchain', 'package.json'), JSON.stringify({ - name: 'prisma', + name: '@prisma/orm-toolchain', version: '8.0.0-rc.4', - dependencies: { '@prisma/cli-engine': '0.1.1' }, + peerDependencies: { '@prisma/cli-engine': '0.1.1' }, }), 'utf-8', ); + mkdirSync(join(projectDir, 'node_modules', '@prisma'), { recursive: true }); + symlinkSync( + join(store, 'orm-postgres'), + join(projectDir, 'node_modules', '@prisma', 'orm-postgres'), + 'junction', + ); const run = await harness().run(scaffoldArgv(), { cwd: projectDir }); expect(run.exitCode).toBe(0); - expect(calls[2]).toEqual({ - file: expect.any(String), - args: ['add', '-D', '@prisma/cli-engine@0.1.1'], - cwd: projectDir, - }); + expect(calls.map((call) => call.args)).toEqual([ + ['add', '@prisma/orm-postgres', 'dotenv'], + ['add', '-D', 'prisma@latest', '@types/node', '@prisma/cli-engine@0.1.1'], + ]); }, timeouts.coldTransformImport, ); @@ -109,7 +145,7 @@ describe('init installs', () => { const run = await harness().run(scaffoldArgv(), { cwd: projectDir }); expect(run.exitCode).toBe(0); - expect(calls.slice(0, 3)).toEqual([ + expect(calls).toEqual([ { file: expect.any(String), args: ['add', '@prisma/orm-postgres', 'dotenv'], @@ -117,12 +153,7 @@ describe('init installs', () => { }, { file: expect.any(String), - args: ['add', '-D', 'prisma@latest', '@types/node'], - cwd: projectDir, - }, - { - file: expect.any(String), - args: ['add', '-D', '@prisma/cli-engine@latest'], + args: ['add', '-D', 'prisma@latest', '@types/node', '@prisma/cli-engine@latest'], cwd: projectDir, }, ]); @@ -221,7 +252,7 @@ describe('init installs', () => { expect(run.exitCode).toBe(0); expect(skillCalls()).toEqual([]); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(2); expect(JSON.stringify(run.presented?.data)).not.toContain('skills sync'); }, timeouts.coldTransformImport, @@ -239,8 +270,7 @@ describe('init installs', () => { expect(calls.map((call) => `${call.file} ${call.args.join(' ')}`)).toEqual([ 'pnpm add @prisma/orm-postgres dotenv', 'npm add @prisma/orm-postgres dotenv', - 'npm add -D prisma@latest @types/node', - 'npm add -D @prisma/cli-engine@latest', + 'npm add -D prisma@latest @types/node @prisma/cli-engine@latest', ]); expect(run.events).toContainEqual( expect.objectContaining({ @@ -325,6 +355,85 @@ describe('init installs', () => { ); }); + describe('build scripts pnpm has not been told to trust', () => { + it( + 'completes when pnpm 12 fails the adds only over ignored build scripts', + async () => { + script = [ + { exitCode: 1, stderr: PNPM_12_IGNORED_BUILDS }, + { exitCode: 1, stderr: PNPM_12_IGNORED_BUILDS }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(0); + expect(calls).toHaveLength(2); + expect(emit).toHaveBeenCalledWith({ cwd: projectDir }); + expect(run.presented?.data).toMatchObject({ + packagesInstalled: { status: 'installed' }, + contractEmitted: true, + warnings: expect.arrayContaining([expect.stringContaining('pnpm approve-builds')]), + }); + }, + timeouts.coldTransformImport, + ); + + it( + 'completes when pnpm 11 exits non-zero after adding the packages', + async () => { + script = [ + { + exitCode: 1, + stderr: 'Command failed with exit code 1: pnpm add @prisma/orm-postgres dotenv', + addsToManifest: true, + }, + { + exitCode: 1, + stderr: + 'Command failed with exit code 1: pnpm add -D prisma@latest @types/node @prisma/cli-engine@latest', + addsToManifest: true, + }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(0); + expect(emit).toHaveBeenCalledWith({ cwd: projectDir }); + expect(run.presented?.data).toMatchObject({ + packagesInstalled: { status: 'installed' }, + warnings: expect.arrayContaining([expect.stringContaining('pnpm approve-builds')]), + }); + }, + timeouts.coldTransformImport, + ); + + it( + 'still fails a pnpm add that exits non-zero without touching package.json', + async () => { + script = [{ exitCode: 1, stderr: '' }]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(4); + expect(emit).not.toHaveBeenCalled(); + }, + timeouts.coldTransformImport, + ); + + it( + 'still fails another manager that exits non-zero the same way', + async () => { + script = [{ exitCode: 1, stderr: PNPM_12_IGNORED_BUILDS, addsToManifest: true }]; + + const run = await harness('npm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(4); + expect(emit).not.toHaveBeenCalled(); + }, + timeouts.coldTransformImport, + ); + }); + describe('--skip-install', () => { it( 'installs nothing and emits nothing', diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-scaffold.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-scaffold.test.ts index 42f6bc8facf8..4b65fe08a5ac 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-scaffold.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-scaffold.test.ts @@ -257,8 +257,8 @@ describe('init scaffold', () => { expect(envelopeOf(run)).toMatchObject({ ok: true }); expect(run.exitCode).toBe(0); - expect(calls[1]?.args).toEqual(['add', '-D', 'prisma@latest']); - expect(calls[2]?.args).toEqual(['add', '-D', '@prisma/cli-engine@latest']); + expect(calls[1]?.args).toEqual(['add', '-D', 'prisma@latest', '@prisma/cli-engine@latest']); + expect(calls).toHaveLength(2); }, timeouts.coldTransformImport, ); From 4684c74118c48e09e38cc94afa6eb421cce5c06c Mon Sep 17 00:00:00 2001 From: brbousnguar Date: Fri, 18 Sep 2026 08:19:41 +0200 Subject: [PATCH 2/2] fix(cli): require pnpm's skipped-builds record before tolerating a failed add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the ignored-builds tolerance: a pnpm `add` that only skipped unapproved build scripts was let through on either of two signals — stderr naming ERR_PNPM_IGNORED_BUILDS (pnpm 12) or package.json changing across the call (pnpm 11, which prints the code on stdout, a stream the package-manager capability does not return). A manifest rewrite on its own names no gate, so any pnpm failure reaching the manifest write would have been reported as a successful install. pnpm also records the packages whose scripts it skipped in the modules manifest it writes beside the tree it linked, at the workspace root rather than the project. The pnpm 11 path now takes that record and the manifest rewrite together: the record is pnpm's own statement that scripts went unrun, the rewrite is what proves this add landed. Neither alone is enough — an earlier install in the same tree leaves the record standing, and a failing approved script exits non-zero without touching package.json. The --skip-install guidance also named the wrong manifest for the @prisma/cli-engine version: it comes from the runtime's @prisma/orm-toolchain peer dependency, which is what the install path reads. Signed-off-by: brbousnguar --- .../3-tooling/cli/src/orm/init-packages.ts | 67 ++++++++++++++--- .../1-framework/3-tooling/cli/src/orm/init.ts | 2 +- .../cli/test/orm/init-install.test.ts | 75 ++++++++++++++++++- 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts b/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts index 39c221f9a8a1..1e050e5656e6 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init-packages.ts @@ -8,6 +8,7 @@ import { redactSecrets } from '../commands/init/redact-secrets'; const ENGINE_PACKAGE = '@prisma/cli-engine'; const TOOLCHAIN_PACKAGE = '@prisma/orm-toolchain'; +const MODULES_MANIFEST = 'node_modules/.modules.yaml'; /** What one install pair produced. */ export interface InstallOutcome { @@ -43,25 +44,73 @@ function readManifest(cwd: string): string | undefined { } } +/** + * Whether pnpm named packages whose scripts it skipped in the modules manifest + * it writes beside the tree it linked — `undefined` when this directory holds + * no manifest to read. pnpm 11 and 12 write it as JSON, which is the YAML its + * name promises; pnpm 10, which has no such gate, wrote real YAML. + */ +function ignoredBuildsRecordedIn(dir: string): boolean | undefined { + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(join(dir, MODULES_MANIFEST), 'utf-8')); + } catch { + return undefined; + } + const ignored = + typeof manifest === 'object' && manifest !== null + ? Reflect.get(manifest, 'ignoredBuilds') + : undefined; + return Array.isArray(ignored) && ignored.length > 0; +} + +/** The modules manifest belongs to the tree pnpm linked, which in a workspace is the root's. */ +function pnpmRecordedIgnoredBuilds(cwd: string): boolean { + let dir = cwd; + for (;;) { + const recorded = ignoredBuildsRecordedIn(dir); + if (recorded !== undefined) { + return recorded; + } + const parent = dirname(dir); + if (parent === dir) { + return false; + } + dir = parent; + } +} + /** * pnpm 11 and later fail an `add` whose dependencies carry build scripts * nobody approved (`ERR_PNPM_IGNORED_BUILDS`) — after the packages are added * and linked, so the scripts are all that did not run. pnpm 12 names the code - * on stderr. pnpm 11 prints it on stdout, which the capability does not hand - * back, so the other tell is the manifest: pnpm rewrites `package.json` only - * once the add has landed, and leaves it untouched when resolution, a fetch, - * or an approved build script fails. + * on stderr, which settles it. + * + * pnpm 11 prints it on stdout, which the capability does not hand back, so + * that version is read from two records pnpm leaves on disk, and it takes both: + * + * - pnpm named packages it skipped in the modules manifest — its own statement + * that scripts went unrun, which an earlier install in the same tree can + * equally have left behind; + * - `package.json` changed across the call — `pnpm add` writes it only once + * resolution, linking and every approved script have landed, and the + * ignored-builds gate is the one thing it raises afterwards. + * + * Either alone admits a real failure: a manifest rewrite names no gate, and a + * failed approved script exits non-zero in a tree whose record already stands. */ function pnpmOnlySkippedBuilds( failure: CliStructuredError, manifestBefore: string | undefined, cwd: string, ): boolean { - return ( - metaString(failure, 'manager') === 'pnpm' && - (metaString(failure, 'stderrTail').includes('ERR_PNPM_IGNORED_BUILDS') || - readManifest(cwd) !== manifestBefore) - ); + if (metaString(failure, 'manager') !== 'pnpm') { + return false; + } + if (metaString(failure, 'stderrTail').includes('ERR_PNPM_IGNORED_BUILDS')) { + return true; + } + return readManifest(cwd) !== manifestBefore && pnpmRecordedIgnoredBuilds(cwd); } function skippedBuildsWarning(failure: CliStructuredError): string { diff --git a/packages/1-framework/3-tooling/cli/src/orm/init.ts b/packages/1-framework/3-tooling/cli/src/orm/init.ts index 6c8b5a78b32c..f2531a5eb111 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/init.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/init.ts @@ -248,7 +248,7 @@ export const createInitCommand = (injected: InitCommandDependencies) => } else { extraActions.push( chooseAction( - `Install the project dependencies with your package manager: ${deps.join(', ')} (and ${devDeps.join(', ')} plus @prisma/cli-engine at the version prisma declares as its dependency, as development dependencies in a single install)`, + `Install the project dependencies with your package manager: ${deps.join(', ')} (and ${devDeps.join(', ')} plus @prisma/cli-engine at the version the runtime's @prisma/orm-toolchain declares as its peer dependency, as development dependencies in a single install)`, ), ); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts index 9dd2c3e1bd3b..5bc7f1ffe16d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-install.test.ts @@ -27,6 +27,11 @@ interface ScriptedResult { readonly exitCode: number; readonly stderr: string; readonly addsToManifest?: boolean; + /** + * The directory pnpm writes its modules manifest in — the project itself, or + * the workspace root it links from — and what it names as skipped there. + */ + readonly modulesManifest?: { readonly dir: string; readonly ignoredBuilds: readonly string[] }; } let projectDir: string; @@ -72,12 +77,25 @@ function addToManifest(cwd: string, args: readonly string[]): void { ); } +/** The modules manifest pnpm writes beside the tree it linked, naming what it skipped. */ +function writeModulesManifest(dir: string, ignoredBuilds: readonly string[]): void { + const modulesDir = join(dir, 'node_modules'); + mkdirSync(modulesDir, { recursive: true }); + writeFileSync( + join(modulesDir, '.modules.yaml'), + JSON.stringify({ ignoredBuilds, pendingBuilds: [] }), + ); +} + const runner: PackageManagerRunner = async (request) => { calls.push({ file: request.file, args: [...request.args], cwd: request.cwd }); const result = script.shift() ?? { exitCode: 0, stderr: '' }; if (result.addsToManifest === true) { addToManifest(request.cwd, request.args); } + if (result.modulesManifest !== undefined) { + writeModulesManifest(result.modulesManifest.dir, result.modulesManifest.ignoredBuilds); + } return { exitCode: result.exitCode, stderr: result.stderr }; }; @@ -379,19 +397,21 @@ describe('init installs', () => { ); it( - 'completes when pnpm 11 exits non-zero after adding the packages', + 'completes when pnpm 11 records skipped scripts and exits non-zero after adding the packages', async () => { script = [ { exitCode: 1, stderr: 'Command failed with exit code 1: pnpm add @prisma/orm-postgres dotenv', addsToManifest: true, + modulesManifest: { dir: projectDir, ignoredBuilds: ['esbuild@0.28.2'] }, }, { exitCode: 1, stderr: 'Command failed with exit code 1: pnpm add -D prisma@latest @types/node @prisma/cli-engine@latest', addsToManifest: true, + modulesManifest: { dir: projectDir, ignoredBuilds: ['esbuild@0.28.2'] }, }, ]; @@ -408,9 +428,58 @@ describe('init installs', () => { ); it( - 'still fails a pnpm add that exits non-zero without touching package.json', + 'reads the record pnpm writes at the workspace root, not in the project', async () => { - script = [{ exitCode: 1, stderr: '' }]; + const workspaceProject = join(projectDir, 'packages', 'database'); + mkdirSync(workspaceProject, { recursive: true }); + const record = { dir: projectDir, ignoredBuilds: ['esbuild@0.28.2'] }; + script = [ + { exitCode: 1, stderr: '', addsToManifest: true, modulesManifest: record }, + { exitCode: 1, stderr: '', addsToManifest: true, modulesManifest: record }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: workspaceProject }); + + expect(run.exitCode).toBe(0); + expect(emit).toHaveBeenCalledWith({ cwd: workspaceProject }); + expect(run.presented?.data).toMatchObject({ + packagesInstalled: { status: 'installed' }, + warnings: expect.arrayContaining([expect.stringContaining('pnpm approve-builds')]), + }); + }, + timeouts.coldTransformImport, + ); + + it( + 'still fails a pnpm add that rewrites package.json while its record names nothing skipped', + async () => { + script = [ + { + exitCode: 1, + stderr: '', + addsToManifest: true, + modulesManifest: { dir: projectDir, ignoredBuilds: [] }, + }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(4); + expect(emit).not.toHaveBeenCalled(); + }, + timeouts.coldTransformImport, + ); + + it( + 'still fails a pnpm add that records skipped scripts without adding the packages', + async () => { + script = [ + { + exitCode: 7, + stderr: '', + modulesManifest: { dir: projectDir, ignoredBuilds: ['esbuild@0.28.2'] }, + }, + ]; const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir });