diff --git a/.changeset/runtime-onfail-node-check.md b/.changeset/runtime-onfail-node-check.md new file mode 100644 index 0000000000..a8163a9ff3 --- /dev/null +++ b/.changeset/runtime-onfail-node-check.md @@ -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`. diff --git a/config/package-is-installable/package.json b/config/package-is-installable/package.json index cba05a3288..e2aa3b6a22 100644 --- a/config/package-is-installable/package.json +++ b/config/package-is-installable/package.json @@ -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:", diff --git a/config/package-is-installable/src/index.ts b/config/package-is-installable/src/index.ts index cfcb77edbf..59310aa6ba 100644 --- a/config/package-is-installable/src/index.ts +++ b/config/package-is-installable/src/index.ts @@ -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' diff --git a/config/package-is-installable/tsconfig.json b/config/package-is-installable/tsconfig.json index 0f4979de1e..fc0dd041cb 100644 --- a/config/package-is-installable/tsconfig.json +++ b/config/package-is-installable/tsconfig.json @@ -25,7 +25,7 @@ "path": "../../core/types" }, { - "path": "../../engine/runtime/system-node-version" + "path": "../../engine/runtime/system-version" } ] } diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index bb4fbdf95b..88be0e365c 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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 diff --git a/core/types/src/package.ts b/core/types/src/package.ts index 98c2d3aa9d..cf929aff4c 100644 --- a/core/types/src/package.ts +++ b/core/types/src/package.ts @@ -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 diff --git a/deps/graph-hasher/package.json b/deps/graph-hasher/package.json index 982f841159..d5d63a4aac 100644 --- a/deps/graph-hasher/package.json +++ b/deps/graph-hasher/package.json @@ -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:*", diff --git a/deps/graph-hasher/src/index.ts b/deps/graph-hasher/src/index.ts index f7146d4b2a..42b726ada1 100644 --- a/deps/graph-hasher/src/index.ts +++ b/deps/graph-hasher/src/index.ts @@ -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' diff --git a/deps/graph-hasher/test/calcGraphNodeHash.test.ts b/deps/graph-hasher/test/calcGraphNodeHash.test.ts index aefe6d0cd3..6d1faa9fd4 100644 --- a/deps/graph-hasher/test/calcGraphNodeHash.test.ts +++ b/deps/graph-hasher/test/calcGraphNodeHash.test.ts @@ -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 diff --git a/deps/graph-hasher/test/index.ts b/deps/graph-hasher/test/index.ts index 7bd0b72d20..8197d46980 100644 --- a/deps/graph-hasher/test/index.ts +++ b/deps/graph-hasher/test/index.ts @@ -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 diff --git a/deps/graph-hasher/tsconfig.json b/deps/graph-hasher/tsconfig.json index 2f3f2385d8..d28b59d81a 100644 --- a/deps/graph-hasher/tsconfig.json +++ b/deps/graph-hasher/tsconfig.json @@ -16,7 +16,7 @@ "path": "../../crypto/object-hasher" }, { - "path": "../../engine/runtime/system-node-version" + "path": "../../engine/runtime/system-version" }, { "path": "../../lockfile/types" diff --git a/engine/runtime/system-node-version/CHANGELOG.md b/engine/runtime/system-node-version/CHANGELOG.md deleted file mode 100644 index 7dd770ef94..0000000000 --- a/engine/runtime/system-node-version/CHANGELOG.md +++ /dev/null @@ -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 `;;node` 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:` 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. diff --git a/engine/runtime/system-node-version/README.md b/engine/runtime/system-node-version/README.md deleted file mode 100644 index 07aba3d15e..0000000000 --- a/engine/runtime/system-node-version/README.md +++ /dev/null @@ -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 diff --git a/engine/runtime/system-version/CHANGELOG.md b/engine/runtime/system-version/CHANGELOG.md new file mode 100644 index 0000000000..aa6fa03203 --- /dev/null +++ b/engine/runtime/system-version/CHANGELOG.md @@ -0,0 +1 @@ +# @pnpm/engine.runtime.system-version diff --git a/engine/runtime/system-version/README.md b/engine/runtime/system-version/README.md new file mode 100644 index 0000000000..f236959d6f --- /dev/null +++ b/engine/runtime/system-version/README.md @@ -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 diff --git a/engine/runtime/system-node-version/package.json b/engine/runtime/system-version/package.json similarity index 77% rename from engine/runtime/system-node-version/package.json rename to engine/runtime/system-version/package.json index 0c5d1e7cc9..1b762e853a 100644 --- a/engine/runtime/system-node-version/package.json +++ b/engine/runtime/system-version/package.json @@ -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" diff --git a/engine/runtime/system-node-version/src/index.ts b/engine/runtime/system-version/src/index.ts similarity index 68% rename from engine/runtime/system-node-version/src/index.ts rename to engine/runtime/system-version/src/index.ts index e1b58bb722..ab98076d22 100644 --- a/engine/runtime/system-node-version/src/index.ts +++ b/engine/runtime/system-version/src/index.ts @@ -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 `;;node` string used as the side-effects diff --git a/engine/runtime/system-node-version/test/getSystemNodeVersion.test.ts b/engine/runtime/system-version/test/getSystemNodeVersion.test.ts similarity index 50% rename from engine/runtime/system-node-version/test/getSystemNodeVersion.test.ts rename to engine/runtime/system-version/test/getSystemNodeVersion.test.ts index 5922908f5e..d5bc08c574 100644 --- a/engine/runtime/system-node-version/test/getSystemNodeVersion.test.ts +++ b/engine/runtime/system-version/test/getSystemNodeVersion.test.ts @@ -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) + 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) + 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) + 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) + 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) + 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 diff --git a/engine/runtime/system-node-version/test/tsconfig.json b/engine/runtime/system-version/test/tsconfig.json similarity index 100% rename from engine/runtime/system-node-version/test/tsconfig.json rename to engine/runtime/system-version/test/tsconfig.json diff --git a/engine/runtime/system-node-version/tsconfig.json b/engine/runtime/system-version/tsconfig.json similarity index 83% rename from engine/runtime/system-node-version/tsconfig.json rename to engine/runtime/system-version/tsconfig.json index 794facae00..ae10b8dac0 100644 --- a/engine/runtime/system-node-version/tsconfig.json +++ b/engine/runtime/system-version/tsconfig.json @@ -11,6 +11,9 @@ "references": [ { "path": "../../../cli/meta" + }, + { + "path": "../../../core/types" } ] } diff --git a/engine/runtime/system-node-version/tsconfig.lint.json b/engine/runtime/system-version/tsconfig.lint.json similarity index 100% rename from engine/runtime/system-node-version/tsconfig.lint.json rename to engine/runtime/system-version/tsconfig.lint.json diff --git a/exec/commands/package.json b/exec/commands/package.json index 45d0eb4b5b..773685c5b3 100644 --- a/exec/commands/package.json +++ b/exec/commands/package.json @@ -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:*", diff --git a/exec/commands/test/dlx.e2e.ts b/exec/commands/test/dlx.e2e.ts index 520b6a0c36..6dd8a044b1 100644 --- a/exec/commands/test/dlx.e2e.ts +++ b/exec/commands/test/dlx.e2e.ts @@ -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') diff --git a/exec/commands/tsconfig.json b/exec/commands/tsconfig.json index cfd305a983..4cf7c3da27 100644 --- a/exec/commands/tsconfig.json +++ b/exec/commands/tsconfig.json @@ -61,7 +61,7 @@ "path": "../../engine/runtime/commands" }, { - "path": "../../engine/runtime/system-node-version" + "path": "../../engine/runtime/system-version" }, { "path": "../../installing/client" diff --git a/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts b/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts index 2462d22d81..59856a0d7c 100644 --- a/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts +++ b/pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62a105b361..5a9fa59a38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm/package.json b/pnpm/package.json index 1bfd63a290..97e2c88f4a 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -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:*", diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index f38c9cca3d..8f4555a2d1 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -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 { 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 ` and * `pnpm --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 = { + 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() + 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"' diff --git a/pnpm/test/packageManagerCheck.test.ts b/pnpm/test/packageManagerCheck.test.ts index 51faff9414..7364a3c69a 100644 --- a/pnpm/test/packageManagerCheck.test.ts +++ b/pnpm/test/packageManagerCheck.test.ts @@ -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: { diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 1a19dd41e8..2d6ffa4148 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -98,6 +98,9 @@ { "path": "../engine/runtime/commands" }, + { + "path": "../engine/runtime/system-version" + }, { "path": "../exec/commands" },