From 92cd573c700b1923bc898aee03bca7e4b78ac92e Mon Sep 17 00:00:00 2001 From: Ash Wu Date: Thu, 22 Oct 2026 22:56:41 +0800 Subject: [PATCH] [build-tools][worker] keep NPM_TOKEN working on newer pnpm and explain pnpm Node.js mismatches pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc (GHSA-3qhv-2rgh-x77r) but still expand them in the npm user config. On EAS Build the NPM_TOKEN credential now goes into the file `npm config get userconfig` reports for the build env (~/.npmrc unless NPM_CONFIG_USERCONFIG points elsewhere) whenever the job has NPM_TOKEN, and the project .npmrc is left to the user. Local builds keep creating the project .npmrc, because the home directory there belongs to the developer. The legacy PREPARE_PROJECT path and the eas/use_npm_token step share one helper. Projects that pin pnpm 11 through packageManager on a Node 20 image crash on node:sqlite right after pnpm's "requires at least Node.js v22.13" banner. A new user error names the eas.json node value to set or the pnpm version to change. The worker raises the same error when it reinstalls pnpm under a node version pinned in eas.json, instead of the generic "Failed to install pnpm" message: the typed error it threw before never reached the log handlers, and the probe's piped output never reached the log. --- .changeset/npm-token-user-npmrc.md | 6 + .../buildErrors/__tests__/detectError.test.ts | 25 ++++ .../pnpmUnsupportedNodeVersionError.ts | 33 +++++ .../src/buildErrors/userErrorHandlers.ts | 2 + packages/build-tools/src/common/setup.ts | 7 +- packages/build-tools/src/index.ts | 1 + .../functions/__tests__/useNpmToken.test.ts | 39 ++++++ .../src/steps/functions/useNpmToken.ts | 33 +---- packages/build-tools/src/templates/npmrc.ts | 4 +- .../src/utils/__tests__/npmrc.test.ts | 120 ++++++++++++++++++ packages/build-tools/src/utils/npmrc.ts | 66 +++++++--- .../src/__unit__/runtimeEnvironment.test.ts | 50 ++++++++ packages/worker/src/runtimeEnvironment.ts | 9 +- 13 files changed, 349 insertions(+), 46 deletions(-) create mode 100644 .changeset/npm-token-user-npmrc.md create mode 100644 packages/build-tools/src/buildErrors/pnpmUnsupportedNodeVersionError.ts create mode 100644 packages/build-tools/src/steps/functions/__tests__/useNpmToken.test.ts create mode 100644 packages/build-tools/src/utils/__tests__/npmrc.test.ts diff --git a/.changeset/npm-token-user-npmrc.md b/.changeset/npm-token-user-npmrc.md new file mode 100644 index 0000000000..53d5855113 --- /dev/null +++ b/.changeset/npm-token-user-npmrc.md @@ -0,0 +1,6 @@ +--- +'@expo/build-tools': patch +'@expo/worker': patch +--- + +Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. diff --git a/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts b/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts index 50ee3672fc..ae8523fe47 100644 --- a/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts +++ b/packages/build-tools/src/buildErrors/__tests__/detectError.test.ts @@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => { expect(err.trackingCode).toBeUndefined(); }); + it('explains that the pnpm version needs a newer Node.js than the build runs', async () => { + const fakeError = new Error(); + const err = await resolveBuildPhaseErrorAsync( + fakeError, + [ + 'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory', + 'warn: This version of pnpm requires at least Node.js v22.13', + 'warn: The current version of Node.js is v20.19.4', + 'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.', + 'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite', + ], + { + job: { platform: Platform.ANDROID } as Job, + phase: BuildPhase.INSTALL_DEPENDENCIES, + env: {}, + }, + '/fake/path' + ); + expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR'); + expect(err.message).toBe( + 'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json' + ); + expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/'); + }); + it('detects xcode line error', async () => { vol.fromJSON({ '/path/to/xcodelogs.log': originalFs.readFileSync( diff --git a/packages/build-tools/src/buildErrors/pnpmUnsupportedNodeVersionError.ts b/packages/build-tools/src/buildErrors/pnpmUnsupportedNodeVersionError.ts new file mode 100644 index 0000000000..884ce4514a --- /dev/null +++ b/packages/build-tools/src/buildErrors/pnpmUnsupportedNodeVersionError.ts @@ -0,0 +1,33 @@ +import { errors } from '@expo/eas-build-job'; +import nullthrows from 'nullthrows'; +import semver from 'semver'; + +import { ErrorHandler } from './errors.types'; + +// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4" +const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP = + /This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/; + +function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError { + const [, requiredNodeVersion, currentNodeVersion] = matchResult; + const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version; + return new errors.UserError( + 'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR', + `This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either: +- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json +- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`, + { docsUrl: 'https://docs.expo.dev/eas/json/' } + ); +} + +export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler = { + regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP, + createError: createPnpmUnsupportedNodeVersionError, +}; + +export function detectPnpmUnsupportedNodeVersionError( + output: string +): errors.UserError | undefined { + const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP); + return match ? createPnpmUnsupportedNodeVersionError(match) : undefined; +} diff --git a/packages/build-tools/src/buildErrors/userErrorHandlers.ts b/packages/build-tools/src/buildErrors/userErrorHandlers.ts index 15a8677508..378f90ba0e 100644 --- a/packages/build-tools/src/buildErrors/userErrorHandlers.ts +++ b/packages/build-tools/src/buildErrors/userErrorHandlers.ts @@ -1,6 +1,7 @@ import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job'; import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types'; +import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError'; import UserError = errors.UserError; @@ -211,6 +212,7 @@ You are seeing this error because either: return undefined; }, }, + pnpmUnsupportedNodeVersionErrorHandler, { platform: Platform.IOS, phase: XCODE_BUILD_PHASE, diff --git a/packages/build-tools/src/common/setup.ts b/packages/build-tools/src/common/setup.ts index 9800789ee0..8dcfa7643b 100644 --- a/packages/build-tools/src/common/setup.ts +++ b/packages/build-tools/src/common/setup.ts @@ -71,7 +71,12 @@ export async function setupAsync( } ); - await setUpNpmrcAsync(ctx, ctx.logger); + await setUpNpmrcAsync({ + projectTargetDirectory: ctx.buildDirectory, + workingDirectory: ctx.getReactNativeProjectDirectory(), + env: ctx.env, + logger: ctx.logger, + }); if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') { await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext); } diff --git a/packages/build-tools/src/index.ts b/packages/build-tools/src/index.ts index 0df3959b93..b5b5746fb7 100644 --- a/packages/build-tools/src/index.ts +++ b/packages/build-tools/src/index.ts @@ -34,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration'; export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs'; export { Hook, runHookIfPresent } from './utils/hooks'; +export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError'; export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile'; export type { GradleProfileTask } from './android/gradleProfile'; diff --git a/packages/build-tools/src/steps/functions/__tests__/useNpmToken.test.ts b/packages/build-tools/src/steps/functions/__tests__/useNpmToken.test.ts new file mode 100644 index 0000000000..3b4710b586 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/useNpmToken.test.ts @@ -0,0 +1,39 @@ +import spawn from '@expo/turtle-spawn'; +import { vol } from 'memfs'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { NpmrcAuthTokenLine } from '../../../templates/npmrc'; +import { createSetUpNpmrcBuildFunction } from '../useNpmToken'; + +jest.mock('fs'); +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); + +const projectTargetDirectory = '/workingdir/project'; +const homeDirectory = '/home/expo'; + +describe(createSetUpNpmrcBuildFunction, () => { + beforeEach(async () => { + vol.reset(); + await vol.promises.mkdir(projectTargetDirectory, { recursive: true }); + await vol.promises.mkdir(homeDirectory, { recursive: true }); + jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => { + const globalCtx = createGlobalContextMock({ projectTargetDirectory }); + globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' }); + const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + callInputs: {}, + }); + + await step.executeAsync(); + + expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain( + NpmrcAuthTokenLine + ); + }); +}); diff --git a/packages/build-tools/src/steps/functions/useNpmToken.ts b/packages/build-tools/src/steps/functions/useNpmToken.ts index a2723d186e..e73243043e 100644 --- a/packages/build-tools/src/steps/functions/useNpmToken.ts +++ b/packages/build-tools/src/steps/functions/useNpmToken.ts @@ -1,9 +1,6 @@ import { BuildFunction } from '@expo/steps'; -import fs from 'fs-extra'; -import path from 'path'; -import { NpmrcTemplate } from '../../templates/npmrc'; -import { findPackagerRootDir } from '../../utils/packageManager'; +import { setUpNpmrcAsync } from '../../utils/npmrc'; export function createSetUpNpmrcBuildFunction(): BuildFunction { return new BuildFunction({ @@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction { name: 'Use NPM_TOKEN', __metricsId: 'eas/use_npm_token', fn: async (stepCtx, { env }) => { - const { logger } = stepCtx; - if (env.NPM_TOKEN) { - logger.info('We detected that you set the NPM_TOKEN environment variable'); - const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc'); - if (await fs.pathExists(projectNpmrcPath)) { - logger.info('.npmrc already exists in your project directory, skipping generation'); - } else { - logger.info('Creating .npmrc in your project directory with the following contents:'); - logger.info(NpmrcTemplate); - await fs.writeFile(projectNpmrcPath, NpmrcTemplate); - } - } else { - const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc'); - if (await fs.pathExists(projectNpmrcPath)) { - logger.info( - `.npmrc found at ${path.relative( - stepCtx.global.projectTargetDirectory, - projectNpmrcPath - )}` - ); - } - } + await setUpNpmrcAsync({ + projectTargetDirectory: stepCtx.global.projectTargetDirectory, + workingDirectory: stepCtx.workingDirectory, + env, + logger: stepCtx.logger, + }); }, }); } diff --git a/packages/build-tools/src/templates/npmrc.ts b/packages/build-tools/src/templates/npmrc.ts index d832bf63ae..627e967013 100644 --- a/packages/build-tools/src/templates/npmrc.ts +++ b/packages/build-tools/src/templates/npmrc.ts @@ -1,3 +1,5 @@ -export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN} +export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}'; + +export const NpmrcTemplate = `${NpmrcAuthTokenLine} registry=https://registry.npmjs.org/ `; diff --git a/packages/build-tools/src/utils/__tests__/npmrc.test.ts b/packages/build-tools/src/utils/__tests__/npmrc.test.ts new file mode 100644 index 0000000000..5230dd16c2 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/npmrc.test.ts @@ -0,0 +1,120 @@ +import spawn from '@expo/turtle-spawn'; +import { vol } from 'memfs'; + +import { createMockLogger } from '../../__tests__/utils/logger'; +import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc'; +import { setUpNpmrcAsync } from '../npmrc'; + +jest.mock('fs'); +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); + +const projectTargetDirectory = '/workingdir/build'; +const homeDirectory = '/home/expo'; + +describe(setUpNpmrcAsync, () => { + beforeEach(async () => { + vol.reset(); + await vol.promises.mkdir(projectTargetDirectory, { recursive: true }); + await vol.promises.mkdir(homeDirectory, { recursive: true }); + jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => { + const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n'; + await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents); + await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false'); + + await setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory: projectTargetDirectory, + env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' }, + logger: createMockLogger(), + }); + + expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe( + `fund=false\n${NpmrcAuthTokenLine}\n` + ); + expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe( + projectContents + ); + }); + + it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => { + await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true }); + jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never); + const env = { + NPM_TOKEN: 'npm_secret_token', + EAS_BUILD_RUNNER: 'eas-build', + NPM_CONFIG_USERCONFIG: '~/npm/npmrc', + }; + + await setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory: projectTargetDirectory, + env, + logger: createMockLogger(), + }); + + expect(spawn).toHaveBeenCalledWith( + 'npm', + ['config', 'get', 'userconfig'], + expect.objectContaining({ cwd: projectTargetDirectory, env }) + ); + expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe( + `\n${NpmrcAuthTokenLine}\n` + ); + expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false); + }); + + it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => { + await setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory: projectTargetDirectory, + env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' }, + logger: createMockLogger(), + }); + + expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe( + NpmrcTemplate + ); + expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false); + }); + + it('leaves an existing project .npmrc untouched on local builds', async () => { + const existingContents = 'registry=https://registry.example.com/\n'; + await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents); + + await setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory: projectTargetDirectory, + env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' }, + logger: createMockLogger(), + }); + + expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe( + existingContents + ); + }); + + it('reports an existing .npmrc when NPM_TOKEN is not set', async () => { + const logger = createMockLogger(); + await vol.promises.writeFile( + `${projectTargetDirectory}/.npmrc`, + 'registry=https://registry.example.com/\n' + ); + + await setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory: projectTargetDirectory, + env: { EAS_BUILD_RUNNER: 'eas-build' }, + logger, + }); + + expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc'); + expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false); + }); +}); diff --git a/packages/build-tools/src/utils/npmrc.ts b/packages/build-tools/src/utils/npmrc.ts index 9c6cf44320..2107b4277d 100644 --- a/packages/build-tools/src/utils/npmrc.ts +++ b/packages/build-tools/src/utils/npmrc.ts @@ -1,23 +1,56 @@ -import { Job } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; import fs from 'fs-extra'; import path from 'path'; import { findPackagerRootDir } from './packageManager'; -import { BuildContext } from '../context'; -import { NpmrcTemplate } from '../templates/npmrc'; +import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc'; -export async function setUpNpmrcAsync(ctx: BuildContext, logger: bunyan): Promise { - if (ctx.env.NPM_TOKEN) { - await createNpmrcIfNotExistsAsync(ctx, logger); +export async function setUpNpmrcAsync({ + projectTargetDirectory, + workingDirectory, + env, + logger, +}: { + projectTargetDirectory: string; + workingDirectory: string; + env: Record; + logger: bunyan; +}): Promise { + if (!env.NPM_TOKEN) { + await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger); + return; + } + logger.info('We detected that you set the NPM_TOKEN environment variable'); + // pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc + if (env.EAS_BUILD_RUNNER === 'eas-build') { + await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger); } else { - await logIfNpmrcExistsAsync(ctx, logger); + await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger); } } -async function createNpmrcIfNotExistsAsync(ctx: BuildContext, logger: bunyan): Promise { - logger.info('We detected that you set the NPM_TOKEN environment variable'); - const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc'); +async function addNpmTokenToUserNpmrcAsync( + workingDirectory: string, + env: Record, + logger: bunyan +): Promise { + const userNpmrcPath = ( + await spawn('npm', ['config', 'get', 'userconfig'], { + cwd: findPackagerRootDir(workingDirectory), + env, + stdio: 'pipe', + }) + ).stdout.trim(); + logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`); + await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`); +} + +async function createProjectNpmrcIfNotExistsAsync( + projectTargetDirectory: string, + logger: bunyan +): Promise { + const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc'); if (await fs.pathExists(projectNpmrcPath)) { logger.info('.npmrc already exists in your project directory, skipping generation'); } else { @@ -27,12 +60,13 @@ async function createNpmrcIfNotExistsAsync(ctx: BuildContext, logger: bunya } } -async function logIfNpmrcExistsAsync(ctx: BuildContext, logger: bunyan): Promise { - const projectNpmrcPath = path.join( - findPackagerRootDir(ctx.getReactNativeProjectDirectory()), - '.npmrc' - ); +async function logIfNpmrcExistsAsync( + projectTargetDirectory: string, + workingDirectory: string, + logger: bunyan +): Promise { + const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc'); if (await fs.pathExists(projectNpmrcPath)) { - logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`); + logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`); } } diff --git a/packages/worker/src/__unit__/runtimeEnvironment.test.ts b/packages/worker/src/__unit__/runtimeEnvironment.test.ts index a7cc3d6c94..66147ea564 100644 --- a/packages/worker/src/__unit__/runtimeEnvironment.test.ts +++ b/packages/worker/src/__unit__/runtimeEnvironment.test.ts @@ -359,6 +359,56 @@ describe('prepareRuntimeEnvironment', () => { ); }); }); + + describe('installPnpm', () => { + const pnpmCtx = { ...ctx, metadata: { requiredPackageManager: 'pnpm' } }; + + function mockPnpmReinstallFailure(stderr: string): void { + let hasProbedPreinstalledPnpm = false; + jest.mocked(spawn).mockImplementation((cmd, args) => { + if (cmd === 'pnpm' && args[0] === '--version') { + if (!hasProbedPreinstalledPnpm) { + hasProbedPreinstalledPnpm = true; + return Promise.resolve(spawnResult); + } + return Promise.reject( + Object.assign(new Error('pnpm --version exited with non-zero code: 1'), { + stdout: '', + stderr, + }) + ); + } + return Promise.resolve(spawnResult); + }); + } + + it('throws the pnpm Node.js version error instead of the generic install error when the reinstalled pnpm cannot run on the build Node.js', async () => { + mockPnpmReinstallFailure( + [ + 'warn: This version of pnpm requires at least Node.js v22.13', + 'warn: The current version of Node.js is v20.18.0', + 'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite', + ].join('\n') + ); + + await expect( + prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false) + ).rejects.toMatchObject({ + errorCode: 'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR', + message: expect.stringContaining( + 'requires Node.js 22.13.0 or newer, but runs Node.js 20.18.0' + ), + }); + }); + + it('reports a pnpm install failure without the Node.js banner as a generic install error', async () => { + mockPnpmReinstallFailure('pnpm: command not found'); + + await expect( + prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false) + ).rejects.toMatchObject({ errorCode: 'EAS_BUILD_SYSTEM_DEPS_INSTALL_ERROR' }); + }); + }); }); function mockProcessPlatform(platform: NodeJS.Platform): void { diff --git a/packages/worker/src/runtimeEnvironment.ts b/packages/worker/src/runtimeEnvironment.ts index ffe4f2d4f3..7bd054cd62 100644 --- a/packages/worker/src/runtimeEnvironment.ts +++ b/packages/worker/src/runtimeEnvironment.ts @@ -1,4 +1,8 @@ -import { BuildContext, RuntimeSettings } from '@expo/build-tools'; +import { + BuildContext, + RuntimeSettings, + detectPnpmUnsupportedNodeVersionError, +} from '@expo/build-tools'; import { Job, errors } from '@expo/eas-build-job'; import templateFile from '@expo/template-file'; import spawn from '@expo/turtle-spawn'; @@ -276,7 +280,8 @@ async function installPnpm({ } catch (err: any) { ctx.logger.error({ err }, `Failed to install pnpm ${versionToInstall}\n`); if (ctx.metadata?.requiredPackageManager === 'pnpm') { - throw new SystemDepsInstallError('pnpm'); + const output = [err.stdout, err.stderr].filter(Boolean).join('\n'); + throw detectPnpmUnsupportedNodeVersionError(output) ?? new SystemDepsInstallError('pnpm'); } else { ctx.markBuildPhaseHasWarnings(); ctx.logger.warn(