mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-02 11:55:17 -04:00
## Summary Adds three end-to-end **GVS parity tests** under `pacquet/crates/cli/tests/pnpm_compatibility.rs` that run `pnpm install` and `pacquet install --frozen-lockfile` against the same workspace + lockfile with `enableGlobalVirtualStore: true`, then diff the resulting `<store>/v11/links/` slot trees. The tests surfaced three independent divergences, each fixed in its own commit set: 1. **`<store>/v11/links` prefix.** `getStorePath` appends `STORE_VERSION` (`v11`) to the configured `storeDir` before `extendInstallOptions.ts:352` joins `'links'` onto it, so pnpm's GVS lives at `<store>/v11/links/` — pacquet's `StoreDir::links()` was one level shallower, joining onto `self.root`. Same gap on `projects()`. Anchored both under `self.v11()` so the on-disk paths agree. 2. **GVS engine-name resolution.** `ENGINE_NAME` was computed from `process.version`, which is wrong in two cases: - **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on PATH that runs lifecycle scripts. Two pnpm installs 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. - **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version, 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. `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)` and `findRuntimeNodeVersion(snapshotKeys)`. The override has priority; otherwise the helper falls through to `getSystemNodeVersion()` — which already prefers shell `node --version` over `process.version` in SEA contexts — and finally to `process.version` as a last resort. `@pnpm/deps.graph-hasher`'s `calcDepState`, `calcGraphNodeHash`, and `iterateHashedGraphNodes` accept an optional `nodeVersion`. Every install-side caller (`deps.graph-builder`, `installing.deps-resolver`, `installing.deps-restorer`, `installing.deps-installer/install/link`, `building.during-install`, `building.after-install`) derives the project's pinned runtime via `findRuntimeNodeVersion` once per invocation and forwards it. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working. Pacquet mirrors this with `find_runtime_node_major` in `install_frozen_lockfile.rs` — it scans the lockfile's `snapshots:` map for a `node@runtime:<version>` entry and uses that major outright, only falling back to the host probe when no pin is present. 3. **Slot bin-shim layout.** Pacquet was emitting `.cmd` / `.ps1` shims on every host platform, even though pnpm only writes them on Windows ([`@zkochan/cmd-shim` `createCmdFile: isWindows`](https://github.com/pnpm/cmd-shim/blob/0d79ca9534/src/index.ts#L32) + `bins/linker`'s [`POWER_SHELL_IS_SUPPORTED = IS_WINDOWS`](https://github.com/pnpm/pnpm/blob/29a42efc3b/bins/linker/src/index.ts#L28) gate). Pacquet also excluded the slot's own package from the slot-local `node_modules/.bin/` based on a stale assumption ("which pnpm doesn't"), but pnpm's [`linkBinsOfDependencies`](https://github.com/pnpm/pnpm/blob/29a42efc3b/building/during-install/src/index.ts#L272-L298) appends `depNode` to the bin-source list unconditionally, so a leaf package like `hello-world-js-bin` writes a self-shim at `<slot>/node_modules/<pkg>/node_modules/.bin/<pkg>`. Both behaviors now match pnpm. ## Test plan - [x] `cargo nextest run -p pacquet-cli --test pnpm_compatibility` — 5 active tests pass, 1 ignored (see below) - [x] `cargo nextest run -p pacquet-store-dir -p pacquet-config -p pacquet-cmd-shim -p pacquet-package-manager` — 600+ tests pass after the prefix / bin-shim updates - [x] `same_global_virtual_store_layout_pure_js` — pacquet & pnpm produce byte-identical `<store>/v11/links/` trees for `@pnpm.e2e/hello-world-js-bin-parent` - [x] `same_global_virtual_store_layout_diamond` — same for `pkg-with-1-dep` + `parent-of-pkg-with-1-dep`, verifying `calc_dep_graph_hash` memoization parity - [x] Three new TS unit tests in `engine/runtime/system-node-version/test/` cover the `engineName(version)` override branch and `findRuntimeNodeVersion`'s extraction rule (with and without peer suffix) - [ ] `same_global_virtual_store_layout_with_approved_postinstall` is currently `#[ignore]`d. It requires pnpm and pacquet to agree on the `<platform>;<arch>;node<major>` triple in the engine-included hash branch. The `pnpm/setup` action on CI installs an `@pnpm/exe` SEA bundle whose embedded Node (node26) differs from the runner's PATH `node` (node24), so the digests don't line up. The pnpm-side fix in this PR resolves `engineName()` via `getSystemNodeVersion()` which prefers the shell `node`, so once a published pnpm version with the fix reaches `pnpm/setup` the test will pass without modification — re-enable it then. The other two GVS parity tests are unaffected since they exercise the engine-agnostic branch. ## Notes - Two pacquet integration tests in `package-manager/src/install/tests.rs` had hard-coded `<store_dir>/projects/` assertions; updated to `<store_dir>/v11/projects/` to follow the prefix fix. - The `link_bins_rewrites_when_only_sh_flavor_exists` cmd-shim test is now `#[cfg(windows)]` — the upgrade-recovery scenario it exercises is meaningless on Unix where `.cmd`/`.ps1` are no longer written in the first place. - Review feedback addressed: (a) test YAML helper now guarantees a trailing newline before appending GVS keys; (b) `findRuntimeNodeVersion` calls in `installing/deps-restorer/` switched from `Object.keys(graph)` (install-dir-keyed in that module) to extracting `depPath` per node, with the computation lifted out of the recursion; (c) `dlx.e2e.ts`'s `jest.unstable_mockModule` against `@pnpm/engine.runtime.system-node-version` now forwards every exported symbol so transitive importers of `engineName` don't break. - Known caveat: pacquet's non-lockfile install path (`run_with_readdir`) still excludes the slot's own bin via `link_bins_excluding`. That path runs only for the legacy flat layout where GVS parity isn't a constraint, so it's deliberately out of scope here. - Known caveat tracked in #11690: when a dependency's own manifest declares `engines.runtime`, the resolver desugars it into a regular `dependencies.node: 'runtime:<v>'` entry on that package, so the **deps** portion of the hash captures it on both sides. The **engine** portion is still install-wide rather than per-snapshot, so cached side-effects for dep-pinned runtimes can be reused under the wrong host Node. pnpm has this same gap today; closing it on both sides requires per-snapshot engine resolution and is outside this PR's scope.
87 lines
4.0 KiB
TypeScript
87 lines
4.0 KiB
TypeScript
import { detectIfCurrentPkgIsExecutable } from '@pnpm/cli.meta'
|
|
import * as execa from 'execa'
|
|
import mem from 'memoize'
|
|
|
|
export function getSystemNodeVersionNonCached (): string | undefined {
|
|
if (detectIfCurrentPkgIsExecutable()) {
|
|
try {
|
|
return execa.sync('node', ['--version']).stdout?.toString()
|
|
} catch {
|
|
// Node.js is not installed on the system
|
|
return undefined
|
|
}
|
|
}
|
|
return process.version
|
|
}
|
|
|
|
export const getSystemNodeVersion = mem(getSystemNodeVersionNonCached)
|
|
|
|
/**
|
|
* The `<platform>;<arch>;node<major>` string used as the side-effects
|
|
* cache-key prefix and the engine portion of the global-virtual-store
|
|
* hash. Identifies the runtime environment that built (or will build)
|
|
* a package's lifecycle scripts — so two installs that materialize the
|
|
* same package on the same host produce the same key.
|
|
*
|
|
* The Node version is resolved in this order:
|
|
*
|
|
* 1. `nodeVersion` argument when provided. Callers use this to thread
|
|
* a project-pinned runtime (`engines.runtime` / `devEngines.runtime`)
|
|
* through to the hash — see {@link findRuntimeNodeVersion} for the
|
|
* helper that extracts the value from a lockfile.
|
|
* 2. {@link getSystemNodeVersion} — the `node` on the user's `PATH`,
|
|
* or `process.version` when not SEA-bundled.
|
|
* 3. `process.version` as a last-resort fallback when the host has
|
|
* no `node` on `PATH` (rare: SEA pnpm with no separately-installed
|
|
* Node). Scripts cannot run in that scenario regardless, so the
|
|
* cache key is effectively unused — the fallback exists only to
|
|
* keep the value deterministic.
|
|
*
|
|
* Anchoring to a project-pinned or script-runner Node — not to pnpm's
|
|
* own `process.version` — matters most when pnpm ships via the
|
|
* `@pnpm/exe` SEA bundle, which has an embedded Node distinct from
|
|
* the one that actually runs lifecycle scripts. Without the override,
|
|
* a project with `devEngines.runtime: node@22` would still hash under
|
|
* the SEA-runner's Node major, splitting the cache across two pnpm
|
|
* installations on the same machine even though both run scripts on
|
|
* the same pinned Node.
|
|
*/
|
|
export function engineName (nodeVersion?: string): string {
|
|
const version = nodeVersion ?? getSystemNodeVersion() ?? process.version
|
|
const stripped = version.startsWith('v') ? version.slice(1) : version
|
|
const major = stripped.split('.')[0]
|
|
return `${process.platform};${process.arch};node${major}`
|
|
}
|
|
|
|
/**
|
|
* Scan an iterable of lockfile snapshot keys for the resolved
|
|
* `engines.runtime` / `devEngines.runtime` Node version and return
|
|
* its bare version string (e.g. `"22.11.0"`), or `undefined` when
|
|
* the project doesn't pin a runtime.
|
|
*
|
|
* Pnpm's runtime resolver writes the pinned Node into the lockfile as
|
|
* a snapshot with key `node@runtime:<version>[(<peers>)]`
|
|
* (see [`engine/runtime/node-resolver/src/index.ts`](https://github.com/pnpm/pnpm/blob/29a42efc3b/engine/runtime/node-resolver/src/index.ts)).
|
|
* The first such key found is treated as authoritative — workspaces
|
|
* with conflicting pins across importers are pathological and the
|
|
* resolver rejects them before they reach the lockfile.
|
|
*
|
|
* Callers typically pass `Object.keys(lockfile.packages ?? {})` — the
|
|
* in-memory `LockfileObject` merges the on-disk `packages:` and
|
|
* `snapshots:` sections under a single `packages` field, so its keys
|
|
* include every snapshot key the install will hash.
|
|
*/
|
|
export function findRuntimeNodeVersion (snapshotKeys: Iterable<string>): string | undefined {
|
|
const prefix = 'node@runtime:'
|
|
for (const key of snapshotKeys) {
|
|
if (!key.startsWith(prefix)) continue
|
|
// Strip peer-context suffix `(...)` — `node@runtime:22.11.0(node@22.11.0)`
|
|
// resolves to the same Node version as `node@runtime:22.11.0`,
|
|
// so peer-stripped and peer-bearing keys yield the same answer.
|
|
const versionWithPeers = key.slice(prefix.length)
|
|
const parenAt = versionWithPeers.indexOf('(')
|
|
return parenAt === -1 ? versionWithPeers : versionWithPeers.slice(0, parenAt)
|
|
}
|
|
return undefined
|
|
}
|