diff --git a/.changeset/runtime-on-fail.md b/.changeset/runtime-on-fail.md new file mode 100644 index 0000000000..a7c2da4a0c --- /dev/null +++ b/.changeset/runtime-on-fail.md @@ -0,0 +1,8 @@ +--- +"@pnpm/config.reader": minor +"@pnpm/installing.commands": minor +"@pnpm/pkg-manifest.utils": minor +"pnpm": minor +--- + +Added a new setting `runtimeOnFail` that overrides the `onFail` field of `devEngines.runtime` (and `engines.runtime`) in the root project's `package.json`. Accepted values: `ignore`, `warn`, `error`, `download`. For example, setting `runtimeOnFail=download` makes pnpm download the declared runtime version even when the manifest does not set `onFail: "download"`. diff --git a/config/reader/package.json b/config/reader/package.json index 6b302c868d..50e5c0aad0 100644 --- a/config/reader/package.json +++ b/config/reader/package.json @@ -41,6 +41,7 @@ "@pnpm/error": "workspace:*", "@pnpm/hooks.pnpmfile": "workspace:*", "@pnpm/network.git-utils": "workspace:*", + "@pnpm/pkg-manifest.utils": "workspace:*", "@pnpm/text.naming-cases": "workspace:*", "@pnpm/types": "workspace:*", "@pnpm/workspace.project-manifest-reader": "workspace:*", diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index f4dac3a680..be2e4f9a47 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -235,6 +235,7 @@ export interface Config extends OptionsFromRootManifest { dedupeInjectedDeps?: boolean nodeOptions?: string pmOnFail?: 'download' | 'error' | 'warn' | 'ignore' + runtimeOnFail?: 'download' | 'error' | 'warn' | 'ignore' virtualStoreDirMaxLength: number peersSuffixMaxLength?: number strictStorePkgContentCheck: boolean diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index 7461ae1cab..a64015e166 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -110,6 +110,7 @@ export const excludedPnpmKeys = [ 'publish-branch', 'recursive-install', 'resolve-peers-from-workspace-root', + 'runtime-on-fail', 'aggregate-output', 'reporter-hide-prefix', 'save-catalog-name', diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index dd0cf89105..c8d15c0425 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -7,6 +7,7 @@ import { createMatcher } from '@pnpm/config.matcher' import { GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants' import { PnpmError } from '@pnpm/error' import { getCurrentBranch } from '@pnpm/network.git-utils' +import { applyRuntimeOnFailOverride } from '@pnpm/pkg-manifest.utils' import { isCamelCase } from '@pnpm/text.naming-cases' import type { DevEngines, EngineDependency, ProjectManifest } from '@pnpm/types' import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader' @@ -627,6 +628,10 @@ export async function getConfig (opts: { } } + if (pnpmConfig.runtimeOnFail && pnpmConfig.rootProjectManifest) { + applyRuntimeOnFailOverride(pnpmConfig.rootProjectManifest, pnpmConfig.runtimeOnFail) + } + const { hooks, finders, allProjects, selectedProjectsGraph, allProjectsGraph, diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 01cf39a98d..0d99194a83 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -97,6 +97,7 @@ export const pnpmTypes = { reporter: String, 'resolution-mode': ['highest', 'time-based', 'lowest-direct'], 'resolve-peers-from-workspace-root': Boolean, + 'runtime-on-fail': ['ignore', 'warn', 'error', 'download'], 'aggregate-output': Boolean, 'reporter-hide-prefix': Boolean, 'save-peer': Boolean, diff --git a/config/reader/test/index.ts b/config/reader/test/index.ts index 6ce973e58f..86c8288aa8 100644 --- a/config/reader/test/index.ts +++ b/config/reader/test/index.ts @@ -106,6 +106,65 @@ test('nodeVersion from config takes priority over devEngines.runtime', async () expect(config.nodeVersion).toBe('20.0.0') }) +test('runtimeOnFail=download overrides devEngines.runtime.onFail and adds node to devDependencies', async () => { + prepare({ + devEngines: { + runtime: { + name: 'node', + version: '22.20.0', + }, + }, + }) + + const { config, context } = await getConfig({ + cliOptions: { + 'runtime-on-fail': 'download', + }, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + }) + + expect(config.runtimeOnFail).toBe('download') + const runtime = context.rootProjectManifest?.devEngines?.runtime + expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({ + name: 'node', + onFail: 'download', + }) + expect(context.rootProjectManifest?.devDependencies?.node).toBe('runtime:22.20.0') +}) + +test('runtimeOnFail=ignore overrides an existing onFail=download and removes node from devDependencies', async () => { + prepare({ + devEngines: { + runtime: { + name: 'node', + version: '22.20.0', + onFail: 'download', + }, + }, + }) + + const { config, context } = await getConfig({ + cliOptions: { + 'runtime-on-fail': 'ignore', + }, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + }) + + expect(config.runtimeOnFail).toBe('ignore') + const runtime = context.rootProjectManifest?.devEngines?.runtime + expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({ + name: 'node', + onFail: 'ignore', + }) + expect(context.rootProjectManifest?.devDependencies?.node).toBeUndefined() +}) + test('throw error if --link-workspace-packages is used with --global', async () => { await expect(getConfig({ cliOptions: { diff --git a/config/reader/tsconfig.json b/config/reader/tsconfig.json index e398195c7e..2784082b86 100644 --- a/config/reader/tsconfig.json +++ b/config/reader/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../../network/git-utils" }, + { + "path": "../../pkg-manifest/utils" + }, { "path": "../../text/naming-cases" }, diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 64aaa393be..1f3e9e34ca 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -19,7 +19,7 @@ import { } from '@pnpm/installing.deps-installer' import type { LockfileObject } from '@pnpm/lockfile.types' import { globalInfo, logger } from '@pnpm/logger' -import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils' +import { applyRuntimeOnFailOverride, filterDependenciesByType } from '@pnpm/pkg-manifest.utils' import type { PreferredVersions, VersionSelectors } from '@pnpm/resolving.resolver-base' import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager' import type { @@ -83,6 +83,7 @@ export type InstallDepsOptions = Pick = { diff --git a/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts b/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts index d0b089af93..f2e0fb33f1 100644 --- a/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts +++ b/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts @@ -5,12 +5,14 @@ import type { ProjectManifest, } from '@pnpm/types' +const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const + export function convertEnginesRuntimeToDependencies ( manifest: ProjectManifest, enginesFieldName: 'devEngines' | 'engines', dependenciesFieldName: DependenciesField ): void { - for (const runtimeName of ['node', 'deno', 'bun']) { + for (const runtimeName of RUNTIME_NAMES) { const enginesFieldRuntime = manifest[enginesFieldName]?.runtime if (enginesFieldRuntime == null || manifest[dependenciesFieldName]?.[runtimeName]) { continue @@ -20,6 +22,10 @@ export function convertEnginesRuntimeToDependencies ( if (runtime?.onFail !== 'download') { continue } + if (!runtime.version) { + globalWarn(`Cannot download ${runtimeName} because no version is specified in ${enginesFieldName}.runtime`) + continue + } if ('webcontainer' in process.versions) { globalWarn(`Installation of ${runtimeName} versions is not supported in WebContainer`) } else { @@ -28,3 +34,32 @@ export function convertEnginesRuntimeToDependencies ( } } } + +export function applyRuntimeOnFailOverride ( + manifest: ProjectManifest, + onFailOverride: 'ignore' | 'warn' | 'error' | 'download' +): void { + for (const [enginesFieldName, dependenciesFieldName] of [ + ['devEngines', 'devDependencies'], + ['engines', 'dependencies'], + ] as const) { + const enginesFieldRuntime = manifest[enginesFieldName]?.runtime + if (enginesFieldRuntime == null) continue + const runtimes: EngineDependency[] = Array.isArray(enginesFieldRuntime) ? enginesFieldRuntime : [enginesFieldRuntime] + for (const runtime of runtimes) { + runtime.onFail = onFailOverride + } + if (onFailOverride !== 'download') { + const deps = manifest[dependenciesFieldName] + if (deps) { + for (const runtimeName of RUNTIME_NAMES) { + if (typeof deps[runtimeName] === 'string' && deps[runtimeName].startsWith('runtime:')) { + delete deps[runtimeName] + } + } + } + } else { + convertEnginesRuntimeToDependencies(manifest, enginesFieldName, dependenciesFieldName) + } + } +} diff --git a/pkg-manifest/utils/test/convertEnginesRuntimeToDependencies.test.ts b/pkg-manifest/utils/test/convertEnginesRuntimeToDependencies.test.ts new file mode 100644 index 0000000000..3397f66480 --- /dev/null +++ b/pkg-manifest/utils/test/convertEnginesRuntimeToDependencies.test.ts @@ -0,0 +1,35 @@ +import { + applyRuntimeOnFailOverride, + convertEnginesRuntimeToDependencies, +} from '@pnpm/pkg-manifest.utils' +import type { ProjectManifest } from '@pnpm/types' + +test('convertEnginesRuntimeToDependencies() skips runtime entries without a version', () => { + const manifest: ProjectManifest = { + devEngines: { + runtime: { + name: 'node', + onFail: 'download', + }, + }, + } + + convertEnginesRuntimeToDependencies(manifest, 'devEngines', 'devDependencies') + + expect(manifest.devDependencies).toBeUndefined() +}) + +test('applyRuntimeOnFailOverride(download) skips runtime entries without a version', () => { + const manifest: ProjectManifest = { + devEngines: { + runtime: { + name: 'node', + }, + }, + } + + applyRuntimeOnFailOverride(manifest, 'download') + + expect(manifest.devEngines?.runtime).toMatchObject({ name: 'node', onFail: 'download' }) + expect(manifest.devDependencies).toBeUndefined() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d019d326c..2d29b523e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2466,6 +2466,9 @@ importers: '@pnpm/network.git-utils': specifier: workspace:* version: link:../../network/git-utils + '@pnpm/pkg-manifest.utils': + specifier: workspace:* + version: link:../../pkg-manifest/utils '@pnpm/text.naming-cases': specifier: workspace:* version: link:../../text/naming-cases diff --git a/pnpm/test/install/runtimeOnFail.ts b/pnpm/test/install/runtimeOnFail.ts new file mode 100644 index 0000000000..ca296de962 --- /dev/null +++ b/pnpm/test/install/runtimeOnFail.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs' + +import { prepare } from '@pnpm/prepare' + +import { execPnpm } from '../utils/index.js' + +test('runtimeOnFail=download causes Node.js to be downloaded even when the manifest does not set onFail', async () => { + const project = prepare({ + devEngines: { + runtime: { + name: 'node', + version: '24.0.0', + }, + }, + }) + fs.writeFileSync('pnpm-workspace.yaml', 'runtimeOnFail: download\n', 'utf8') + + await execPnpm(['install']) + + project.isExecutable('.bin/node') + const lockfile = project.readLockfile() + expect(lockfile.importers['.'].devDependencies).toStrictEqual({ + node: { + specifier: 'runtime:24.0.0', + version: 'runtime:24.0.0', + }, + }) +}) + +test('runtimeOnFail=ignore prevents Node.js download even when manifest sets onFail=download', async () => { + const project = prepare({ + devEngines: { + runtime: { + name: 'node', + version: '24.0.0', + onFail: 'download', + }, + }, + }) + fs.writeFileSync('pnpm-workspace.yaml', 'runtimeOnFail: ignore\n', 'utf8') + + await execPnpm(['install']) + + const lockfile = project.readLockfile() + expect(lockfile.importers['.'].devDependencies).toBeUndefined() +})