feat: add runtimeOnFail setting (#11277)

* feat: add runtimeOnFail setting

Adds a `runtimeOnFail` config setting ('ignore' | 'warn' | 'error' |
'download') that overrides the `onFail` field on `devEngines.runtime`
and `engines.runtime` in the root project's package.json. This makes
it possible to opt into (or out of) runtime auto-download without
changing the project manifest.

* fix: skip runtime download when version is missing

Without a version, convertEnginesRuntimeToDependencies would write
`runtime:undefined` into the manifest. Warn and skip instead.

* feat: apply runtimeOnFail override during install

The config reader override only mutates the context's rootProjectManifest,
but installDeps reads the manifest fresh via tryReadProjectManifest and
findWorkspaceProjects. Apply the override there too so `runtimeOnFail`
actually affects what gets installed. Adds an e2e test covering both
download and ignore overrides through the real CLI bundle.
This commit is contained in:
Zoltan Kochan
2026-04-17 12:00:17 +02:00
committed by GitHub
parent 75942bfac5
commit ff7733ce21
13 changed files with 208 additions and 2 deletions

View File

@@ -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"`.

View File

@@ -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:*",

View File

@@ -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

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -36,6 +36,9 @@
{
"path": "../../network/git-utils"
},
{
"path": "../../pkg-manifest/utils"
},
{
"path": "../../text/naming-cases"
},

View File

@@ -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<Config,
| 'production'
| 'preferWorkspacePackages'
| 'registries'
| 'runtimeOnFail'
| 'save'
| 'saveDev'
| 'saveExact'
@@ -200,6 +201,11 @@ export async function installDeps (
? await findWorkspaceProjects(opts.workspaceDir, { ...opts, patterns: opts.workspacePackagePatterns })
: []
)
if (opts.runtimeOnFail) {
for (const project of allProjects) {
applyRuntimeOnFailOverride(project.manifest, opts.runtimeOnFail)
}
}
if (opts.workspaceDir) {
const selectedProjectsGraph = opts.selectedProjectsGraph ?? selectProjectByDir(allProjects, opts.dir)
if (selectedProjectsGraph != null) {
@@ -255,6 +261,8 @@ export async function installDeps (
throw new PnpmError('NO_PKG_MANIFEST', `No package.json found in ${opts.dir}`)
}
manifest = {}
} else if (opts.runtimeOnFail) {
applyRuntimeOnFailOverride(manifest, opts.runtimeOnFail)
}
const installOpts: Omit<MutateModulesOptions, 'allProjects'> = {

View File

@@ -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)
}
}
}

View File

@@ -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()
})

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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()
})