fix: validate devEngines runtime onFail (#11822)

Fixes #11818

## Summary

`devEngines.runtime` / `engines.runtime` entries with `onFail: error` or `warn` silently did nothing — only `onFail: download` had any effect. This PR wires up validation for all three supported runtimes (node, deno, bun).

- Add `getSystemDenoVersion` / `getSystemBunVersion` and a generic `getSystemRuntimeVersion(name)` dispatcher in the runtime-version helper package.
- Walk each runtime entry in the manifest during pnpm startup, compare to the live system runtime, and throw `ERR_PNPM_BAD_RUNTIME_VERSION` (or warn) on a mismatch. Invalid ranges (e.g. `"invalid range"`) are reported instead of crashing `semver.minVersion`. Missing runtimes ("no Node.js on the system") get the same error path.
- The shell-out for deno/bun only runs when the manifest configures them AND `onFail` is `error`/`warn`. `download`/`ignore` short-circuit, and projects with no runtime pin pay nothing. Memoized per runtime.
- `pnpm --version`, `pnpm --help`, and `pnpm <cmd> --global` are exempt from the check.
- Rename `@pnpm/engine.runtime.system-node-version` → `@pnpm/engine.runtime.system-version` to match its broader scope; hoist `RuntimeName` / `RUNTIME_NAMES` / `isRuntimeAlias` to `@pnpm/types` so callers don't need to depend on `pkg-manifest.utils` just for the alias check.

## Tests

- `pnpm --filter pnpm run compile`
- `pnpm --filter pnpm exec jest packageManagerCheck.test` — 42 passing. New coverage: node/deno/bun version mismatch, invalid range, missing range, multi-entry runtime arrays, `engines.runtime` path (not just `devEngines.runtime`), and the `pnpm --version` exemption.
- `pnpm --filter @pnpm/engine.runtime.system-version test` — 10 passing, 100% statement coverage; unit tests for each helper and the dispatcher.
- Manual end-to-end smoke tests against the rebuilt bundle for deno and bun version mismatch.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added runtime version validation for Node.js, Deno, and Bun. The system now enforces `devEngines.runtime` and `engines.runtime` declarations with configurable failure behavior (`error`, `warn`, or `ignore`).
  * Enhanced error messages for runtime version mismatches with helpful suggestions for overrides.

* **Improvements**
  * Improved system runtime detection and version checking across multiple runtime environments.

---------

Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Puneet Dixit
2026-05-26 13:59:40 +05:30
committed by GitHub
parent a1f6f32996
commit 35d235542e
30 changed files with 511 additions and 219 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/engine.runtime.system-version": minor
"@pnpm/types": minor
"@pnpm/config.reader": patch
"pnpm": patch
---
Validate `devEngines.runtime` and `engines.runtime` version ranges for `node`, `deno`, and `bun` when `onFail` is set to `error` or `warn`. Previously these settings only had an effect with `onFail: 'download'` — the `error` and `warn` modes silently did nothing [#11818](https://github.com/pnpm/pnpm/issues/11818). Violations now throw `ERR_PNPM_BAD_RUNTIME_VERSION`.

View File

@@ -34,7 +34,7 @@
"dependencies": {
"@pnpm/cli.meta": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/engine.runtime.system-node-version": "workspace:*",
"@pnpm/engine.runtime.system-version": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/types": "workspace:*",
"detect-libc": "catalog:",

View File

@@ -2,7 +2,7 @@ import {
installCheckLogger,
skippedOptionalDependencyLogger,
} from '@pnpm/core-loggers'
import { getSystemNodeVersion } from '@pnpm/engine.runtime.system-node-version'
import { getSystemNodeVersion } from '@pnpm/engine.runtime.system-version'
import type { SupportedArchitectures } from '@pnpm/types'
import { checkEngine, UnsupportedEngineError, type WantedEngine } from './checkEngine.js'

View File

@@ -25,7 +25,7 @@
"path": "../../core/types"
},
{
"path": "../../engine/runtime/system-node-version"
"path": "../../engine/runtime/system-version"
}
]
}

View File

@@ -850,6 +850,7 @@ function getNodeVersionFromEnginesRuntime (manifest: ProjectManifest): string |
const runtimes: EngineDependency[] = Array.isArray(enginesRuntime) ? enginesRuntime : [enginesRuntime]
const nodeRuntime = runtimes.find((r) => r.name === 'node')
if (nodeRuntime?.version == null) continue
if (!semver.validRange(nodeRuntime.version)) continue
const minVersion = semver.minVersion(nodeRuntime.version)
if (minVersion != null) {
return minVersion.version

View File

@@ -53,6 +53,14 @@ export interface DependenciesMeta {
}
}
export const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const
export type RuntimeName = typeof RUNTIME_NAMES[number]
export function isRuntimeAlias (alias: string): alias is RuntimeName {
return (RUNTIME_NAMES as readonly string[]).includes(alias)
}
export interface EngineDependency {
name: string
version?: string

View File

@@ -33,7 +33,7 @@
"dependencies": {
"@pnpm/crypto.object-hasher": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/engine.runtime.system-node-version": "workspace:*",
"@pnpm/engine.runtime.system-version": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*",
"@pnpm/resolving.resolver-base": "workspace:*",

View File

@@ -1,6 +1,6 @@
import { hashObject, hashObjectWithoutSorting } from '@pnpm/crypto.object-hasher'
import { getPkgIdWithPatchHash, refToRelative } from '@pnpm/deps.path'
import { engineName } from '@pnpm/engine.runtime.system-node-version'
import { engineName } from '@pnpm/engine.runtime.system-version'
import type { LockfileObject, LockfileResolution, PackageSnapshot } from '@pnpm/lockfile.types'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import { resolvePlatformSelector, selectPlatformVariant } from '@pnpm/resolving.resolver-base'

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from '@jest/globals'
import { hashObject, hashObjectWithoutSorting } from '@pnpm/crypto.object-hasher'
import { calcGraphNodeHash, type DepsGraph, type DepsStateCache, type PkgMeta } from '@pnpm/deps.graph-hasher'
import { engineName } from '@pnpm/engine.runtime.system-node-version'
import { engineName } from '@pnpm/engine.runtime.system-version'
import type { DepPath, PkgIdWithPatchHash } from '@pnpm/types'
// Track the same script-runner-Node value the production code uses

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from '@jest/globals'
import { hashObject, hashObjectWithoutSorting } from '@pnpm/crypto.object-hasher'
import { calcDepState, calcGraphNodeHash, findRuntimeNodeVersion, readSnapshotRuntimePin } from '@pnpm/deps.graph-hasher'
import { engineName } from '@pnpm/engine.runtime.system-node-version'
import { engineName } from '@pnpm/engine.runtime.system-version'
import type { DepPath, PkgIdWithPatchHash } from '@pnpm/types'
// Match the function the production code uses (see

View File

@@ -16,7 +16,7 @@
"path": "../../crypto/object-hasher"
},
{
"path": "../../engine/runtime/system-node-version"
"path": "../../engine/runtime/system-version"
},
{
"path": "../../lockfile/types"

View File

@@ -1,142 +0,0 @@
# @pnpm/env.system-node-version
## 1100.1.1
### Patch Changes
- @pnpm/cli.meta@1100.0.4
## 1100.1.0
### Minor Changes
- 3ddde2b: **fix**: anchor the side-effects-cache key and global-virtual-store hash to the project's script-runner Node — `engines.runtime` pin when present, shell `node` otherwise — instead of pnpm's own runtime.
`ENGINE_NAME` (the `<platform>;<arch>;node<major>` prefix used as the side-effects-cache key and the engine portion of the GVS hash) was computed from `process.version` — the Node that runs pnpm itself. That was wrong in two situations:
1. **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on the user's `PATH` that actually spawns lifecycle scripts. Two pnpm installations on the same machine (one SEA, one npm-package) therefore disagreed on the cache key, partitioning the side-effects cache and the global virtual store across two Node majors even though both installs would run scripts on the same shell `node`.
2. **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version via `devEngines.runtime` (pnpm v11+), pnpm downloads that Node into `node_modules/node/` and uses it to run lifecycle scripts. But the hash still anchored to whichever Node ran pnpm itself, not to the pinned Node — so two installs of the same project with two different runner Nodes would still disagree on the GVS slot path even though scripts run on the same pinned Node.
Three changes:
- `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)`. Resolves the version in this order: explicit override → `getSystemNodeVersion()` (which already prefers `node --version` over `process.version` in SEA contexts) → `process.version`.
- `@pnpm/deps.graph-hasher` now exports `findRuntimeNodeVersion(snapshotKeys)` — scans an iterable of lockfile snapshot keys for a `node@runtime:<version>` entry and returns its bare version string. `calcDepState` and `calcGraphNodeHash`/`iterateHashedGraphNodes` accept a `nodeVersion?` (in the options bag for the first, as a trailing parameter / ctx field for the others), forwarded to `engineName()`. The default (no override) preserves the pre-change behaviour. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working; in non-SEA, non-pinned contexts every value lines up.
- Every install-side caller of the graph-hasher (`@pnpm/installing.deps-resolver`, `@pnpm/installing.deps-restorer`, `@pnpm/installing.deps-installer`, `@pnpm/building.during-install`, `@pnpm/building.after-install`, `@pnpm/deps.graph-builder`) now derives the project's pinned runtime via `findRuntimeNodeVersion(Object.keys(graph))` once per invocation and threads it through.
On upgrade, two one-time GVS slot churns are possible:
- **SEA-pnpm users** without a runtime pin: slots that previously hashed under the embedded-Node major (e.g. `node26`) now hash under the shell-Node major (e.g. `node24`), matching what pacquet, the npm-published `pnpm` package, and any other pnpm-compatible tool already produce.
- **Projects with a `devEngines.runtime` pin**: slots that previously hashed under the runner's Node major now hash under the pinned Node major, matching what the lifecycle scripts will actually run on.
In both cases the old slots become prune-eligible.
## 1100.0.3
### Patch Changes
- @pnpm/cli.meta@1100.0.3
## 1100.0.2
### Patch Changes
- 184ce26: Fix the package name in README.md.
- Updated dependencies [184ce26]
- @pnpm/cli.meta@1100.0.2
## 1100.0.1
### Patch Changes
- @pnpm/cli.meta@1100.0.1
## 1001.0.0
### Major Changes
- 491a84f: This package is now pure ESM.
- 7d2fd48: Node.js v18, 19, 20, and 21 support discontinued.
### Patch Changes
- Updated dependencies [491a84f]
- Updated dependencies [7d2fd48]
- @pnpm/cli.meta@1001.0.0
## 1000.0.11
### Patch Changes
- @pnpm/cli-meta@1000.0.11
## 1000.0.10
### Patch Changes
- @pnpm/cli-meta@1000.0.10
## 1000.0.9
### Patch Changes
- @pnpm/cli-meta@1000.0.9
## 1000.0.8
### Patch Changes
- @pnpm/cli-meta@1000.0.8
## 1000.0.7
### Patch Changes
- @pnpm/cli-meta@1000.0.7
## 1000.0.6
### Patch Changes
- @pnpm/cli-meta@1000.0.6
## 1000.0.5
### Patch Changes
- @pnpm/cli-meta@1000.0.5
## 1000.0.4
### Patch Changes
- @pnpm/cli-meta@1000.0.4
## 1000.0.3
### Patch Changes
- @pnpm/cli-meta@1000.0.3
## 1000.0.2
### Patch Changes
- @pnpm/cli-meta@1000.0.2
## 1000.0.1
### Patch Changes
- @pnpm/cli-meta@1000.0.1
## 1.0.1
### Patch Changes
- e476b07: Don't crash if the `use-node-version` setting is used and the system has no Node.js installed [#8769](https://github.com/pnpm/pnpm/issues/8769).
## 1.0.0
### Major Changes
- d04f7f2: Initial release.

View File

@@ -1,15 +0,0 @@
# @pnpm/engine.runtime.system-node-version
> Detects the current system node version
[![npm version](https://img.shields.io/npm/v/@pnpm/engine.runtime.system-node-version.svg)](https://npmx.dev/package/@pnpm/engine.runtime.system-node-version)
## Installation
```sh
pnpm add @pnpm/engine.runtime.system-node-version
```
## License
MIT

View File

@@ -0,0 +1 @@
# @pnpm/engine.runtime.system-version

View File

@@ -0,0 +1,15 @@
# @pnpm/engine.runtime.system-version
> Detects the current system version of supported runtimes (Node.js, Deno, Bun)
[![npm version](https://img.shields.io/npm/v/@pnpm/engine.runtime.system-version.svg)](https://npmx.dev/package/@pnpm/engine.runtime.system-version)
## Installation
```sh
pnpm add @pnpm/engine.runtime.system-version
```
## License
MIT

View File

@@ -1,7 +1,7 @@
{
"name": "@pnpm/engine.runtime.system-node-version",
"version": "1100.1.1",
"description": "Detects the current system node version",
"name": "@pnpm/engine.runtime.system-version",
"version": "1100.0.0-0",
"description": "Detects the current system version of supported runtimes (Node.js, Deno, Bun)",
"keywords": [
"pnpm",
"pnpm11",
@@ -9,8 +9,8 @@
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/engine/runtime/system-node-version",
"homepage": "https://github.com/pnpm/pnpm/tree/main/engine/runtime/system-node-version#readme",
"repository": "https://github.com/pnpm/pnpm/tree/main/engine/runtime/system-version",
"homepage": "https://github.com/pnpm/pnpm/tree/main/engine/runtime/system-version#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
@@ -33,12 +33,13 @@
},
"dependencies": {
"@pnpm/cli.meta": "workspace:*",
"@pnpm/types": "workspace:*",
"execa": "catalog:",
"memoize": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/engine.runtime.system-node-version": "workspace:*"
"@pnpm/engine.runtime.system-version": "workspace:*"
},
"engines": {
"node": ">=22.13"

View File

@@ -1,4 +1,5 @@
import { detectIfCurrentPkgIsExecutable } from '@pnpm/cli.meta'
import type { RuntimeName } from '@pnpm/types'
import * as execa from 'execa'
import mem from 'memoize'
@@ -14,7 +15,38 @@ export function getSystemNodeVersionNonCached (): string | undefined {
return process.version
}
export function getSystemDenoVersionNonCached (): string | undefined {
try {
// `deno --version` prints e.g. "deno 1.40.0 (release, ...)\nv8 ..."
const output = execa.sync('deno', ['--version']).stdout?.toString() ?? ''
const match = /^deno\s+(\d+\.\d+\.\d\S*)/m.exec(output)
return match?.[1] ? `v${match[1]}` : undefined
} catch {
return undefined
}
}
export function getSystemBunVersionNonCached (): string | undefined {
try {
// `bun --version` prints just the bare version, e.g. "1.1.0".
const output = execa.sync('bun', ['--version']).stdout?.toString().trim() ?? ''
return /^\d+\.\d+\.\d+/.test(output) ? `v${output}` : undefined
} catch {
return undefined
}
}
export const getSystemNodeVersion = mem(getSystemNodeVersionNonCached)
export const getSystemDenoVersion = mem(getSystemDenoVersionNonCached)
export const getSystemBunVersion = mem(getSystemBunVersionNonCached)
export function getSystemRuntimeVersion (name: RuntimeName): string | undefined {
switch (name) {
case 'node': return getSystemNodeVersion()
case 'deno': return getSystemDenoVersion()
case 'bun': return getSystemBunVersion()
}
}
/**
* The `<platform>;<arch>;node<major>` string used as the side-effects

View File

@@ -11,7 +11,13 @@ jest.unstable_mockModule('execa', () => ({
})),
}))
const { getSystemNodeVersionNonCached, engineName } = await import('../lib/index.js')
const {
getSystemNodeVersionNonCached,
getSystemDenoVersionNonCached,
getSystemBunVersionNonCached,
getSystemRuntimeVersion,
engineName,
} = await import('../lib/index.js')
const execa = await import('execa')
test('getSystemNodeVersion() executed from an executable pnpm CLI', () => {
@@ -36,6 +42,54 @@ test('getSystemNodeVersion() returns undefined if execa.sync throws an error', (
expect(execa.sync).toHaveBeenCalledWith('node', ['--version'])
})
test('getSystemDenoVersion() parses the first line of `deno --version`', () => {
jest.mocked(execa.sync).mockReturnValueOnce({
stdout: 'deno 1.40.0 (release, aarch64-apple-darwin)\nv8 12.1.285.27\ntypescript 5.3.3',
} as ReturnType<typeof execa.sync>)
expect(getSystemDenoVersionNonCached()).toBe('v1.40.0')
expect(execa.sync).toHaveBeenCalledWith('deno', ['--version'])
})
test('getSystemDenoVersion() returns undefined when deno is missing or output is unexpected', () => {
jest.mocked(execa.sync).mockImplementationOnce(() => {
throw new Error('not found: deno')
})
expect(getSystemDenoVersionNonCached()).toBeUndefined()
jest.mocked(execa.sync).mockReturnValueOnce({ stdout: 'unexpected output' } as ReturnType<typeof execa.sync>)
expect(getSystemDenoVersionNonCached()).toBeUndefined()
})
test('getSystemBunVersion() parses the bare version printed by `bun --version`', () => {
jest.mocked(execa.sync).mockReturnValueOnce({ stdout: '1.1.0\n' } as ReturnType<typeof execa.sync>)
expect(getSystemBunVersionNonCached()).toBe('v1.1.0')
expect(execa.sync).toHaveBeenCalledWith('bun', ['--version'])
})
test('getSystemBunVersion() returns undefined when bun is missing', () => {
jest.mocked(execa.sync).mockImplementationOnce(() => {
throw new Error('not found: bun')
})
expect(getSystemBunVersionNonCached()).toBeUndefined()
})
test('getSystemRuntimeVersion() dispatches to the per-runtime helpers', () => {
isSea = false
expect(getSystemRuntimeVersion('node')).toBe(process.version)
jest.mocked(execa.sync).mockReturnValueOnce({
stdout: 'deno 9.9.9 (release)',
} as ReturnType<typeof execa.sync>)
expect(getSystemRuntimeVersion('deno')).toBe('v9.9.9')
expect(execa.sync).toHaveBeenLastCalledWith('deno', ['--version'])
jest.mocked(execa.sync).mockReturnValueOnce({
stdout: '9.9.9\n',
} as ReturnType<typeof execa.sync>)
expect(getSystemRuntimeVersion('bun')).toBe('v9.9.9')
expect(execa.sync).toHaveBeenLastCalledWith('bun', ['--version'])
})
test('engineName() honours an explicit nodeVersion over the host probe', () => {
// The pinned-runtime override path: when a project's
// `engines.runtime` / `devEngines.runtime` resolves to a specific

View File

@@ -11,6 +11,9 @@
"references": [
{
"path": "../../../cli/meta"
},
{
"path": "../../../core/types"
}
]
}

View File

@@ -77,7 +77,7 @@
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/engine.runtime.system-node-version": "workspace:*",
"@pnpm/engine.runtime.system-version": "workspace:*",
"@pnpm/exec.commands": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/prepare": "workspace:*",

View File

@@ -9,13 +9,13 @@ import { DLX_DEFAULT_OPTS as DEFAULT_OPTS } from './utils/index.js'
const {
getSystemNodeVersion: originalGetSystemNodeVersion,
engineName: originalEngineName,
} = await import('@pnpm/engine.runtime.system-node-version')
} = await import('@pnpm/engine.runtime.system-version')
// Re-export every public symbol the package surfaces so downstream
// dynamic imports (e.g. `@pnpm/deps.graph-hasher`'s use of
// `engineName` for the GVS hash) keep working under the mock. Only
// `getSystemNodeVersion` is wrapped with `jest.fn` for spy-ability;
// `engineName` delegates straight back to the original.
jest.unstable_mockModule('@pnpm/engine.runtime.system-node-version', () => ({
jest.unstable_mockModule('@pnpm/engine.runtime.system-version', () => ({
getSystemNodeVersion: jest.fn(originalGetSystemNodeVersion),
engineName: originalEngineName,
}))
@@ -29,7 +29,7 @@ jest.unstable_mockModule('@pnpm/installing.commands', () => ({
},
}))
const systemNodeVersion = await import('@pnpm/engine.runtime.system-node-version')
const systemNodeVersion = await import('@pnpm/engine.runtime.system-version')
const { add } = await import('@pnpm/installing.commands')
const { dlx } = await import('@pnpm/exec.commands')
const { approveBuilds } = await import('@pnpm/building.commands')

View File

@@ -61,7 +61,7 @@
"path": "../../engine/runtime/commands"
},
{
"path": "../../engine/runtime/system-node-version"
"path": "../../engine/runtime/system-version"
},
{
"path": "../../installing/client"

View File

@@ -1,18 +1,11 @@
import { globalWarn } from '@pnpm/logger'
import type {
DependenciesField,
EngineDependency,
ProjectManifest,
import {
type DependenciesField,
type EngineDependency,
type ProjectManifest,
RUNTIME_NAMES,
} from '@pnpm/types'
export const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const
export type RuntimeName = typeof RUNTIME_NAMES[number]
export function isRuntimeAlias (alias: string): alias is RuntimeName {
return (RUNTIME_NAMES as readonly string[]).includes(alias)
}
export function convertEnginesRuntimeToDependencies (
manifest: ProjectManifest,
enginesFieldName: 'devEngines' | 'engines',

22
pnpm-lock.yaml generated
View File

@@ -2592,9 +2592,9 @@ importers:
'@pnpm/core-loggers':
specifier: workspace:*
version: link:../../core/core-loggers
'@pnpm/engine.runtime.system-node-version':
'@pnpm/engine.runtime.system-version':
specifier: workspace:*
version: link:../../engine/runtime/system-node-version
version: link:../../engine/runtime/system-version
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
@@ -3375,9 +3375,9 @@ importers:
'@pnpm/deps.path':
specifier: workspace:*
version: link:../path
'@pnpm/engine.runtime.system-node-version':
'@pnpm/engine.runtime.system-version':
specifier: workspace:*
version: link:../../engine/runtime/system-node-version
version: link:../../engine/runtime/system-version
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
@@ -4229,11 +4229,14 @@ importers:
specifier: 'catalog:'
version: 7.7.1
engine/runtime/system-node-version:
engine/runtime/system-version:
dependencies:
'@pnpm/cli.meta':
specifier: workspace:*
version: link:../../../cli/meta
'@pnpm/types':
specifier: workspace:*
version: link:../../../core/types
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0
@@ -4244,7 +4247,7 @@ importers:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/engine.runtime.system-node-version':
'@pnpm/engine.runtime.system-version':
specifier: workspace:*
version: 'link:'
@@ -4368,9 +4371,9 @@ importers:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/engine.runtime.system-node-version':
'@pnpm/engine.runtime.system-version':
specifier: workspace:*
version: link:../../engine/runtime/system-node-version
version: link:../../engine/runtime/system-version
'@pnpm/exec.commands':
specifier: workspace:*
version: 'link:'
@@ -7787,6 +7790,9 @@ importers:
'@pnpm/engine.runtime.commands':
specifier: workspace:*
version: link:../engine/runtime/commands
'@pnpm/engine.runtime.system-version':
specifier: workspace:*
version: link:../engine/runtime/system-version
'@pnpm/error':
specifier: workspace:*
version: link:../core/error

View File

@@ -100,6 +100,7 @@
"@pnpm/deps.path": "workspace:*",
"@pnpm/engine.pm.commands": "workspace:*",
"@pnpm/engine.runtime.commands": "workspace:*",
"@pnpm/engine.runtime.system-version": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/exec.commands": "workspace:*",
"@pnpm/hooks.pnpmfile": "workspace:*",

View File

@@ -12,9 +12,10 @@ import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
import { getSystemRuntimeVersion } from '@pnpm/engine.runtime.system-version'
import { PnpmError } from '@pnpm/error'
import { globalWarn, logger } from '@pnpm/logger'
import type { EngineDependency } from '@pnpm/types'
import { type EngineDependency, isRuntimeAlias, type RuntimeName } from '@pnpm/types'
import { finishWorkers } from '@pnpm/worker'
import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader'
import { filterProjectsFromDir } from '@pnpm/workspace.projects-filter'
@@ -103,25 +104,32 @@ export async function main (inputArgv: string[]): Promise<void> {
workspaceDir,
onlyInheritDlxSettingsFromLocal: isDlxOrCreateCommand,
}) as { config: typeof config, context: ConfigContext })
if (cmd !== 'setup' && context.wantedPackageManager != null && !shouldSkipPmHandling(cmd, cliParams)) {
const pm = context.wantedPackageManager
if (pm.onFail !== 'ignore') {
if (pm.name === 'pnpm' && pm.onFail === 'download' && !isExecutedByCorepack()) {
// Corepack owns version switching; pnpm only switches versions when
// the user is running pnpm directly.
await switchCliVersion(config, context)
} else if (cliOptions.global) {
globalWarn('Using --global skips the package manager check for this project')
} else {
// checkPackageManager and syncEnvLockfile run regardless of how pnpm
// was invoked. Different developers on the same project may use
// corepack or invoke pnpm directly, and the lockfile's
// `packageManagerDependencies` entry must stay consistent across both
// workflows. syncEnvLockfile self-gates via shouldPersistLockfile so
// it only writes to the lockfile when the project opted in (via
// `devEngines.packageManager`, or a v12+ `packageManager` pin).
checkPackageManager(pm, { underCorepack: isExecutedByCorepack() })
await syncEnvLockfile(config, context)
if (cmd !== 'setup' && !shouldSkipPmHandling(cmd, cliParams)) {
if (context.wantedPackageManager != null) {
const pm = context.wantedPackageManager
if (pm.onFail !== 'ignore') {
if (pm.name === 'pnpm' && pm.onFail === 'download' && !isExecutedByCorepack()) {
// Corepack owns version switching; pnpm only switches versions when
// the user is running pnpm directly.
await switchCliVersion(config, context)
} else if (cliOptions.global) {
globalWarn('Using --global skips the package manager check for this project')
} else {
// checkPackageManager and syncEnvLockfile run regardless of how pnpm
// was invoked. Different developers on the same project may use
// corepack or invoke pnpm directly, and the lockfile's
// `packageManagerDependencies` entry must stay consistent across both
// workflows. syncEnvLockfile self-gates via shouldPersistLockfile so
// it only writes to the lockfile when the project opted in (via
// `devEngines.packageManager`, or a v12+ `packageManager` pin).
checkPackageManager(pm, { underCorepack: isExecutedByCorepack() })
await syncEnvLockfile(config, context)
}
}
}
if (cmd != null && !cliOptions.global) {
for (const runtime of getWantedRuntimes(context)) {
checkRuntime(runtime)
}
}
}
@@ -394,8 +402,8 @@ function printError (message: string, hint?: string): void {
}
/**
* Whether to skip the packageManager/devEngines handling block (both auto
* download and warn/error check). Returns true when the command itself
* Whether to skip the packageManager/runtime handling block (both auto
* download and warn/error checks). Returns true when the command itself
* opts out via `skipPackageManagerCheck: true`, or when the user is asking
* for help on such a command — `pnpm help <skippable>` and
* `pnpm <skippable> --help` (which parse-cli-args rewrites to the same
@@ -443,3 +451,67 @@ function checkPackageManager (pm: EngineDependency, opts: { underCorepack: boole
}
}
}
const RUNTIME_DISPLAY_NAMES: Record<RuntimeName, string> = {
node: 'Node.js',
deno: 'Deno',
bun: 'Bun',
}
// devEngines.runtime takes precedence over engines.runtime per the iteration
// order below: the first entry seen for a given runtime wins.
function getWantedRuntimes (context: ConfigContext): EngineDependency[] {
const manifest = context.rootProjectManifest
if (manifest == null) return []
const result: EngineDependency[] = []
const seen = new Set<RuntimeName>()
for (const enginesFieldName of ['devEngines', 'engines'] as const) {
const enginesRuntime = manifest[enginesFieldName]?.runtime
if (enginesRuntime == null) continue
const runtimes: EngineDependency[] = Array.isArray(enginesRuntime) ? enginesRuntime : [enginesRuntime]
for (const runtime of runtimes) {
if (!runtime.name || !isRuntimeAlias(runtime.name) || seen.has(runtime.name)) continue
seen.add(runtime.name)
result.push(runtime)
}
}
return result
}
function checkRuntime (runtime: EngineDependency): void {
if (runtime.onFail == null || runtime.onFail === 'ignore' || runtime.onFail === 'download') return
if (!runtime.name || !isRuntimeAlias(runtime.name)) return
const runtimeName: RuntimeName = runtime.name
const displayName = RUNTIME_DISPLAY_NAMES[runtimeName]
const wantedRange = runtime.version
if (!wantedRange || !semver.validRange(wantedRange)) {
const msg = wantedRange
? `This project requires an invalid ${displayName} version range: ${wantedRange}`
: `This project requires a ${displayName} runtime but does not specify a version range`
failRuntimeCheck(runtime.onFail, msg)
return
}
const currentVersion = getSystemRuntimeVersion(runtimeName)
if (currentVersion == null) {
failRuntimeCheck(
runtime.onFail,
`This project requires ${displayName} ${wantedRange}, but ${displayName} was not found on the system`
)
return
}
if (semver.satisfies(currentVersion, wantedRange, { includePrerelease: true })) return
failRuntimeCheck(
runtime.onFail,
`This project requires ${displayName} ${wantedRange}. Your current ${displayName} is ${currentVersion}`
)
}
function failRuntimeCheck (onFail: 'error' | 'warn', message: string): void {
if (onFail === 'error') {
throw new PnpmError('BAD_RUNTIME_VERSION', message, { hint: RUNTIME_ON_FAIL_HINT })
}
globalWarn(message)
}
const RUNTIME_ON_FAIL_HINT = 'If you want to bypass this version check, set "runtimeOnFail" to "warn" or "ignore" (e.g. via --runtime-on-fail=ignore), or set "devEngines.runtime.onFail"/"engines.runtime.onFail" to "warn" or "ignore"'

View File

@@ -143,6 +143,257 @@ test('devEngines.packageManager with onFail=ignore should not check version', as
expect(stderr.toString()).not.toContain('0.0.1')
})
test('devEngines.runtime with onFail=error should fail on Node.js version mismatch', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires Node.js 99999.0.0')
})
test('devEngines.runtime with onFail=warn should warn on Node.js version mismatch', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'warn',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
const output = stdout.toString() + stderr.toString()
expect(status).toBe(0)
expect(output).toContain('This project requires Node.js 99999.0.0')
})
test('devEngines.runtime with onFail=ignore should not check Node.js version', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'ignore',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
const output = stdout.toString() + stderr.toString()
expect(status).toBe(0)
expect(output).not.toContain('99999.0.0')
})
test('engines.runtime with onFail=error should fail on Node.js version mismatch', async () => {
prepare({
engines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires Node.js 99999.0.0')
})
test('engines.runtime with onFail=warn should warn on Node.js version mismatch', async () => {
prepare({
engines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'warn',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
const output = stdout.toString() + stderr.toString()
expect(status).toBe(0)
expect(output).toContain('This project requires Node.js 99999.0.0')
})
test('engines.runtime with onFail=error should fail on invalid Node.js version range', async () => {
prepare({
engines: {
runtime: {
name: 'node',
version: 'invalid range',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires an invalid Node.js version range: invalid range')
})
test('devEngines.runtime with onFail=error should fail on invalid Node.js version range', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: 'invalid range',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires an invalid Node.js version range: invalid range')
expect(stderr.toString()).toContain('--runtime-on-fail=ignore')
})
test('devEngines.runtime with onFail=warn should warn on invalid Node.js version range', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: 'invalid range',
onFail: 'warn',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
const output = stdout.toString() + stderr.toString()
expect(status).toBe(0)
expect(output).toContain('This project requires an invalid Node.js version range: invalid range')
})
test('devEngines.runtime with onFail=error should fail when Node.js version range is missing', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires a Node.js runtime but does not specify a version range')
})
test('devEngines.runtime with onFail=error should fail on invalid Deno version range', async () => {
prepare({
devEngines: {
runtime: {
name: 'deno',
version: 'invalid range',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires an invalid Deno version range: invalid range')
})
test('devEngines.runtime with onFail=error should fail on invalid Bun version range', async () => {
prepare({
devEngines: {
runtime: {
name: 'bun',
version: 'invalid range',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires an invalid Bun version range: invalid range')
})
test('devEngines.runtime array entries are checked beyond the first one', async () => {
prepare({
devEngines: {
runtime: [
{
name: 'node',
version: '*',
onFail: 'error',
},
{
name: 'deno',
version: 'invalid range',
onFail: 'error',
},
],
},
})
const { status, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project requires an invalid Deno version range: invalid range')
})
test('devEngines.runtime with onFail=ignore should not check Deno version', async () => {
prepare({
devEngines: {
runtime: {
name: 'deno',
version: 'invalid range',
onFail: 'ignore',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--config.verify-deps-before-run=false', 'exec', 'node', '--version'])
const output = stdout.toString() + stderr.toString()
expect(status).toBe(0)
expect(output).not.toContain('invalid Deno version range')
})
test('devEngines.runtime with onFail=error should not block version output', async () => {
prepare({
devEngines: {
runtime: {
name: 'node',
version: '99999.0.0',
onFail: 'error',
},
},
})
const { status, stdout, stderr } = execPnpmSync(['--version'])
expect(status).toBe(0)
expect(stdout.toString()).toMatch(/\d+\.\d+\.\d+/)
expect(stderr.toString()).not.toContain('This project requires Node.js 99999.0.0')
})
test('devEngines.packageManager defaults to onFail=download (#11676)', async () => {
prepare({
devEngines: {

View File

@@ -98,6 +98,9 @@
{
"path": "../engine/runtime/commands"
},
{
"path": "../engine/runtime/system-version"
},
{
"path": "../exec/commands"
},