diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/pnpm-fallback.ts b/packages/1-framework/3-tooling/cli/src/commands/init/pnpm-fallback.ts index 1512b0ac9886..ce8a1495d23f 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/pnpm-fallback.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/pnpm-fallback.ts @@ -22,3 +22,8 @@ export function isRecognisedPnpmResolutionError(stderr: string): boolean { /catalog:[^\s]* is not a valid (version|spec)/i.test(stderr) ); } + +export function isPnpmIgnoredBuildsError(stderr: string): boolean { + if (!stderr) return false; + return stderr.includes('ERR_PNPM_IGNORED_BUILDS'); +} 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..d7f6ef098949 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 @@ -3,7 +3,10 @@ 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 { isRecognisedPnpmResolutionError } from '../commands/init/pnpm-fallback'; +import { + isPnpmIgnoredBuildsError, + isRecognisedPnpmResolutionError, +} from '../commands/init/pnpm-fallback'; import { redactSecrets } from '../commands/init/redact-secrets'; /** What one install pair produced, and which manager finished it. */ @@ -36,6 +39,13 @@ function pnpmLeakedASpecifier(failure: CliStructuredError): boolean { ); } +function pnpmIgnoredBuilds(failure: CliStructuredError): boolean { + return ( + metaString(failure, 'manager') === 'pnpm' && + isPnpmIgnoredBuildsError(metaString(failure, 'stderrTail')) + ); +} + /** * The engine redacts the stderr it hands back, and this redacts it again * before quoting it: what the engine strips is its own business, and registry @@ -65,6 +75,29 @@ function retriedWarning(failure: CliStructuredError): string { .join('\n'); } +function ignoredBuildsFallbackWarning(failure: CliStructuredError): string { + const firstLine = redactSecrets(metaString(failure, 'stderrTail')).trim().split('\n')[0] ?? ''; + return [ + 'pnpm could not install: ignored build scripts (ERR_PNPM_IGNORED_BUILDS).', + 'Falling back to npm so init can complete.', + firstLine === '' ? '' : ` pnpm error: ${firstLine}`, + 'Both installs ran under npm, which writes a package-lock.json beside the pnpm lockfile — delete whichever of the two you do not want to keep.', + 'To stay on pnpm, add `allowBuilds` for esbuild, msgpackr-extract, workerd or set `strictDepBuilds: false` in pnpm-workspace.yaml.', + ] + .filter((line) => line !== '') + .join('\n'); +} + +function ignoredBuildsRetriedWarning(failure: CliStructuredError): string { + const firstLine = redactSecrets(metaString(failure, 'stderrTail')).trim().split('\n')[0] ?? ''; + return [ + 'pnpm failed first with ignored build scripts (ERR_PNPM_IGNORED_BUILDS), so init retried with npm.', + firstLine === '' ? '' : ` pnpm error: ${firstLine}`, + ] + .filter((line) => line !== '') + .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 @@ -131,18 +164,23 @@ export async function installProjectDependencies(ctx: { if (failure === undefined) { return { failure: undefined, manager: undefined, warnings: ctx.catalogWarnings }; } - if (!pnpmLeakedASpecifier(failure)) { + const isLeaked = pnpmLeakedASpecifier(failure); + const isIgnoredBuilds = pnpmIgnoredBuilds(failure); + if (!isLeaked && !isIgnoredBuilds) { return { failure, manager: undefined, warnings: [] }; } const retryFailure = await pair('npm'); if (retryFailure !== 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: retryFailure, + manager: undefined, + warnings: [isIgnoredBuilds ? ignoredBuildsRetriedWarning(failure) : retriedWarning(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, + manager: 'npm', + warnings: [isIgnoredBuilds ? ignoredBuildsFallbackWarning(failure) : fallbackWarning(failure)], + }; } 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..9f4350cae4f7 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 @@ -35,6 +35,9 @@ 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'; +const PNPM_IGNORED_BUILDS = + 'ERR_PNPM_IGNORED_BUILDS Ignored build scripts: esbuild@0.25.11, msgpackr-extract@3.0.3, workerd@1.20251003.0'; + beforeEach(() => { projectDir = createTestProjectDir('orm-init-install'); calls = []; @@ -323,6 +326,81 @@ describe('init installs', () => { }, timeouts.coldTransformImport, ); + + it( + 'retries the pair with npm when pnpm reports ignored builds', + async () => { + script = [{ exitCode: 1, stderr: PNPM_IGNORED_BUILDS }]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(0); + 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', + ]); + expect(run.events).toContainEqual( + expect.objectContaining({ + kind: 'message', + severity: 'warn', + text: expect.stringContaining('ERR_PNPM_IGNORED_BUILDS'), + }), + ); + expect(run.events).toContainEqual( + expect.objectContaining({ + kind: 'message', + severity: 'warn', + text: expect.stringContaining('allowBuilds'), + }), + ); + }, + timeouts.coldTransformImport, + ); + + it( + 'keeps registry credentials out of the ignored-builds warning', + async () => { + script = [ + { + exitCode: 1, + stderr: `${PNPM_IGNORED_BUILDS} https://alice:hunter2@registry.example.com/ //registry.npmjs.org/:_authToken=npm_realsecret`, + }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + const warnings = run.presented?.data; + + expect(JSON.stringify(warnings)).not.toContain('hunter2'); + expect(JSON.stringify(warnings)).not.toContain('npm_realsecret'); + expect(warnings).toMatchObject({ + warnings: expect.arrayContaining([expect.stringContaining('ERR_PNPM_IGNORED_BUILDS')]), + }); + }, + timeouts.coldTransformImport, + ); + + it( + 'completes at exit 4 with retried warning when npm fails after ignored builds', + async () => { + script = [ + { exitCode: 1, stderr: PNPM_IGNORED_BUILDS }, + { exitCode: 1, stderr: 'npm ERR! 404 Not Found' }, + ]; + + const run = await harness('pnpm').run(scaffoldArgv(), { cwd: projectDir }); + + expect(run.exitCode).toBe(4); + expect(envelopeOf(run)).toMatchObject({ + diagnostics: [{ code: 'CLI.INIT_INSTALL_FAILED' }], + }); + expect(run.presented?.data).toMatchObject({ + warnings: expect.arrayContaining([expect.stringContaining('ERR_PNPM_IGNORED_BUILDS')]), + }); + }, + timeouts.coldTransformImport, + ); }); describe('--skip-install', () => {