When running from the standalone executable, `pnpm setup` installs pnpm
via `pnpm add -g file:<dir>`. The shipped `@pnpm/exe` package.json carries
preinstall/prepare scripts, which triggered a build-approval prompt for
pnpm's own install. pnpm links the platform-specific binary itself, so
these scripts are unnecessary (and unrunnable on a Node-less host); pass
--ignore-scripts to skip them.
Closes https://github.com/pnpm/pnpm/issues/12377
pnpm can be made to download and execute a native binary through two **repository-controlled** inputs, neither of which was authenticated before this change:
1. **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`) in `configDependencies` (in `pnpm-workspace.yaml`) opts in to pnpm's Rust install engine, and pnpm spawns the platform binary `@pacquet/<platform>-<arch>` during `pnpm install`.
2. **package-manager version switch** — the `packageManager` / `devEngines.packageManager` field makes pnpm download and run a specific pnpm version. This is **on by default** (`onFail` defaults to `download`) and also covers `pnpm self-update` and `pnpm with`.
In both cases the repository also controls the lockfile integrity and the registry the bytes are fetched from (via `.npmrc`), so matching the lockfile integrity proves nothing — it matches the hash the attacker wrote. A cloned, untrusted repository could therefore execute an arbitrary native binary just by running a normal pnpm command.
## Fix (corepack-style registry-signature verification)
pnpm now verifies the **npm registry signature** of the bytes it is about to spawn, **over the installed integrity**, against npm's public signing keys that **ship embedded in the pnpm CLI** (exactly as corepack does). If the bytes on disk were substituted or tampered with, npm's real signature does not validate over them.
- New reusable `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures` verifies `name@version:integrity` against `dist.signatures` using the embedded keys.
- Because the keys are **embedded** (not fetched), a registry the user did not vouch for cannot supply its own keypair to forge a signature. The signed packument is fetched from the **configured** registry, so an **npm mirror works transparently** — it proxies the same signed packument, with no configuration. There is intentionally **no runtime override or off-switch** for the keys.
- **pacquet** (`installing/commands`): verifies the `pacquet` shim and the host platform binary. It **fails the command** if the signature does not verify or cannot be checked (e.g. registry unreachable); the only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
- **pnpm engine** (`engine/pm/commands`): verifies `pnpm`, `@pnpm/exe`, and the host platform binary, **only on a store cache miss** (an actual download), so it adds no network round trip to every command. It **fails closed** — any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary.
## Keeping the embedded keys fresh
The embedded keys live in a generated file. `deps/security/signatures/scripts/update-npm-signing-keys.mjs` keeps them in sync with npm's keys endpoint (`pnpm check:npm-signing-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate, so a key rotation cannot silently break verification — a stale key set blocks the release until refreshed.
## Pacquet parity
pacquet gained `configDependencies` support on `main` (#12285), but it has **no install-engine-spawn sink** — pacquet *is* the engine, and it does not select/spawn an alternate engine from `configDependencies` (its only config-dependency code-execution path is `updateConfig` plugin pnpmfiles, which it shares with pnpm and which this advisory does not cover). So CAND-PNPM-097 has no pacquet-side analog; no pacquet code change is needed.
* fix(engine.pm.commands): honor minimumReleaseAgeExclude in self-update
* refactor(config.version-policy): centralize publishedBy policy derivation
Extract the publishedBy / publishedByExclude derivation duplicated across
selfUpdate, dlx, outdated, and deps-resolver into a new
`getPublishedByPolicy()` helper, and the version-policy error rewrap
into `createPackageVersionPolicyOrThrow()`.
Also adds the global self-update test branch (no wantedPackageManager)
requested in PR review, and harmonizes the dlx/outdated error code
for invalid minimumReleaseAgeExclude patterns with install/self-update.
* style(config.version-policy): rename 'callsite' to 'call site' to satisfy cspell
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* test(exe): add Windows-only repro for #11486 (pn/pnpx/pnx aliases)
Captures the user-reported failure on a fresh Windows CI: when the
@pnpm/exe install rewrites bin entries to point at .cmd files,
@zkochan/cmd-shim's Bash shim does `exec cmd /C ...target.cmd`, MSYS2
mangles the lone `/C` into a Windows path, and cmd.exe falls into
interactive mode (printing its banner instead of running the alias).
These tests will fail on `windows-latest` until the follow-up commit
points the bin entries at .exe hardlinks of the SEA binary.
* fix(exe): route pn/pnpx/pnx through .exe hardlinks on Windows (#11486)
The @pnpm/exe install rewrote bin to point pn/pnpx/pnx at .cmd files,
which cmd-shim wraps as `exec cmd /C ...target.cmd "$@"` in its Bash
shim. MSYS2 / Git Bash mangles the lone `/C` into a Windows path
before cmd.exe sees it, so cmd.exe finds no /C or /K and falls into
interactive mode — the user sees its banner instead of `pnpm dlx`.
Hardlink pn.exe / pnpx.exe / pnx.exe to the SEA pnpm.exe (in setup.js
preinstall and in self-update's linkExePlatformBinary) and rewrite
those bin entries to the .exe names. cmd-shim emits a direct exec for
.exe sources, taking cmd.exe out of the chain entirely. The SEA reads
process.execPath's basename and prepends `dlx` when launched as
pnpx / pnx.
* test(exe): make Windows alias tests robust to local-dev environments
Two follow-ups from Copilot review on #11501:
* Use `'junction'` instead of `'dir'` for the detect-libc symlink on
Windows. Non-junction directory symlinks need Developer Mode or
admin, which the existing failure-path tests already skip on Windows
for; junctions don't.
* Probe \`bash --version\` before running the Git Bash / MSYS2 alias
test, and skip cleanly if it isn't on PATH (local Windows dev
machines often lack it; CI windows-latest ships it). Fold the status
check into the assertion so a non-zero exit surfaces in the diff.
* test(exe): wire @pnpm/exe into the recursive test runner
The setup.test.ts in this package wasn't running in CI — `@pnpm/exe`
had no `.test` script, so `pn -r .test` (what `test-pkgs-all` runs)
silently skipped it. The existing tests there have apparently been
dead since they were added; the Windows alias repro added in 1e93a1d
inherited the same gap.
Add `.test` (jest invocation, matching every other workspace
package's shape) and a `test` alias so it's picked up by the
recursive runner. meta-updater's @pnpm/exe / artifacts branch
short-circuits before adding test scripts; preserve that behavior by
hand-writing them rather than restructuring the rule.
pnpm v10 setup added PNPM_HOME (not PNPM_HOME/bin) to PATH and wrote
a pnpm bootstrap shim there. After upgrading to v11, that shim still
points into the old .tools/<version> install, so PATH continues to
resolve `pnpm` to the pre-update version even though the new version
was installed under global/v11.
Detect that layout during self-update, refresh the shims at PNPM_HOME
so the upgrade actually takes effect, and warn the user to run
`pnpm setup` for a clean migration to the v11 PATH layout.
Closes#11464.
* fix(self-update): do not downgrade when latest dist-tag is older
`pnpm self-update` defaults to the `latest` dist-tag, but `latest` on the
registry can lag the installed version when a new major has shipped
without being tagged. Refuse to downgrade in that case. Users can still
run `pnpm self-update latest` (explicit) to force the downgrade.
Closes#11418
* fix(self-update): use lockfile-pinned version for project-pin downgrade check
When a project pins pnpm via a range (e.g. `devEngines.packageManager.version: ">=8.0.0"`)
and the env lockfile pins an exact version above the range's lower bound,
the previous guard compared the resolved `latest` against `semver.minVersion(spec)`
and missed the downgrade. Read `packageManagerDependencies.pnpm.version` from
`pnpm-lock.yaml` and use the max of (lockfile-pinned, spec.minVersion) as the
current version. Also fix the explicit-`latest` test which mocked `latest`
as newer than the current version, defeating its own assertion.
* chore(engine.pm.commands): add lockfile/fs project reference to tsconfig
* fix: sync packageManager and devEngines.packageManager on self-update
When `package.json` declares both `packageManager` and
`devEngines.packageManager`, `pnpm self-update` previously bumped only
the latter — leaving Corepack (which reads `packageManager`) pinned to
the old version until a manual edit.
Now, when `packageManager` pins pnpm, both fields are rewritten to the
new exact version on update: `packageManager` to `pnpm@<version>`
(without an integrity hash) and `devEngines.packageManager.version` to
the same exact `<version>` (dropping any range operator). When only
`devEngines.packageManager` is declared, the existing range-preserving
behavior is unchanged.
Closes#11388
* refactor: export and reuse parsePackageManager from @pnpm/config.reader
Drop the inline duplicate in self-updater and use the existing
parser from config.reader. Same parsing rules (strips integrity
hash, rejects URL-style refs).
* refactor: collapse devEngines.packageManager array/object branches
Resolve to the underlying pnpm entry first (whether the field is an
array or an object) and run the version-update logic once, instead of
duplicating it across both branches.
* chore: upgrade @typescript/native-preview to 7.0.0-dev.20260421.2
- Add explicit `types: ["node"]` to the shared tsconfig because tsgo
20260421 no longer auto-acquires `@types/*` from `node_modules`.
- Refactor test files to explicitly import jest globals (`describe`,
`it`, `test`, `expect`, `beforeEach`, etc.) from `@jest/globals`
instead of relying on `@types/jest` ambient declarations. Under the
new tsgo build, `import { jest } from '@jest/globals'` shadows the
ambient `jest` namespace, breaking `@types/jest`'s `declare var
describe: jest.Describe;` globals.
- Add `@jest/globals` to each package's devDependencies where tests
now import from it, and add `@types/node` to packages that need it
but were relying on hoisted resolution.
- Replace `fail()` calls with `throw new Error(...)` since `fail` is
no longer globally available.
* chore: fix remaining tsgo type-strictness errors
- Strip `as <PnpmType>` casts on objects passed to toMatchObject /
toStrictEqual / toEqual; @jest/globals rejects the typed objects
(which include AsymmetricMatchers) vs. the repo-specific type.
- Type `jest.fn<...>()` explicitly where the mock's signature matters
for toHaveBeenCalledWith.
- Replace `beforeEach(() => X)` with `beforeEach(() => { X })` so the
return value is void, as the stricter jest typing requires.
- Use `expect.objectContaining({...})` in one place where the full
expected object triggered stricter type resolution.
- Cast `prompt.mock.calls` arg through `as unknown as Record<...>[]`
for patch.test.ts's nested-array matchers.
- Fix off-by-one `<reference path>` in pnpm/test/getConfig.test.ts
that only surfaced now.
- Move `@jest/globals` from devDependencies to dependencies in the
two `__utils__` packages that import it from `src/`.
- Clean up unused imports from the @jest/globals migration.
* chore: address Copilot review on #11332
- Move misplaced `@jest/globals` imports to the top import block in
checkEngine, run.ts, and workspace/root-finder tests where the
script dropped them below executable code.
- Replace `try { await x(); throw new Error('should have thrown') } catch`
in bins/linker, lockfile/fs, and resolving/local-resolver tests with
`await expect(x()).rejects.toMatchObject({...})`. The old pattern
swallowed an unrelated `throw` if the under-test call silently
succeeded, which would fail on the catch-block assertion with a
misleading message.
* fix(exe): restore legacy @pnpm/{macos,win,linux,linuxstatic}-{x64,arm64} package names
Reverts the published package names renamed in #11316 back to the legacy
scheme so `pnpm self-update` from v10 continues to resolve. v10's
self-updater looks up the platform child by its legacy name; the
scope-nested `@pnpm/exe.<platform>-<arch>[-musl]` rename broke that lookup.
Workspace directory layout (`pnpm/artifacts/<platform>-<arch>[-musl]/`)
and the GitHub release asset filenames (`pnpm-linux-x64-musl.tar.gz`,
`pnpm-darwin-*.tar.gz`, `pnpm-win32-*.zip`) stay on the new scheme.
`linkExePlatformBinary` now checks both legacy and future naming
schemes, so a later rename can ship without a v10-compatibility hazard.
* style: fix indentation in setup.test.ts
* refactor: extract legacyOsSegment into a switch helper
* refactor: defer platform detection until after exe dir check
* test: use familySync() in fixtures so musl hosts match implementation
Test fixtures were passing null for libcFamily while linkExePlatformBinary
and setup.js both use detect-libc at runtime. On musl Linux the fixtures
built linux-*/exe.linux-* while the implementation looked up
linuxstatic-*/exe.linux-*-musl. Also bump @pnpm/exe in the changeset.
* refactor: rename @pnpm/exe platform packages to @pnpm/exe.<platform>-<arch>[-musl]
Aligns pnpm's own published platform artifacts with the one naming
convention the rest of the codebase already uses (`process.platform`
values plus an explicit `-musl` libc suffix), matching what `pnpm
pack-app`, `pnpm add --os/--cpu/--libc`, `supportedArchitectures.os`,
and Node.js tarball names all already settled on.
Package renames:
- @pnpm/linux-x64 -> @pnpm/exe.linux-x64
- @pnpm/linux-arm64 -> @pnpm/exe.linux-arm64
- @pnpm/linuxstatic-x64 -> @pnpm/exe.linux-x64-musl (new dir)
- @pnpm/linuxstatic-arm64 -> @pnpm/exe.linux-arm64-musl
- @pnpm/macos-x64 -> @pnpm/exe.darwin-x64
- @pnpm/macos-arm64 -> @pnpm/exe.darwin-arm64
- @pnpm/win-x64 -> @pnpm/exe.win32-x64
- @pnpm/win-arm64 -> @pnpm/exe.win32-arm64
GitHub release asset names follow suit (`pnpm-linuxstatic-x64.tar.gz`
-> `pnpm-linux-x64-musl.tar.gz`, `pnpm-macos-*` -> `pnpm-darwin-*`,
`pnpm-win-*` -> `pnpm-win32-*`). Internal artifact directories under
`pnpm/artifacts/` renamed to match, which drops the awkward mixed
naming between target and directory.
The umbrella package `@pnpm/exe` keeps its name so that `pnpm
self-update` from v10 and any `npm i -g @pnpm/exe` scripts continue to
resolve. Platform children can be renamed freely because npm/pnpm
filter optional deps by each child's `os`/`cpu`/`libc` manifest
fields, not by package names.
Also updates:
- `@pnpm/exe`'s `setup.js` (preinstall) and the self-updater's
`linkExePlatformBinary` to look up the platform package by the new
scheme, using `detect-libc` to append `-musl` on musl Linux hosts.
- `.meta-updater` optional-dependency list for @pnpm/exe.
- `copy-artifacts.ts` target list and Windows detection prefix.
- cspell wordlist (drops `linuxstatic`; it's no longer used anywhere).
Final transition publishes of the old package names (pointing at the
new ones so direct pins keep resolving) are a release-engineering step
handled separately.
Refs #11314.
* chore: keep "linuxstatic" in cspell wordlist for changeset references
* test(pack-app rename): cover the musl branch of platform-package-name lookup
Copilot flagged that the musl -> -musl suffix logic in setup.js's preinstall
and self-updater's linkExePlatformBinary had no regression coverage. Extract
the name-computation from both into small pure helpers and unit-test all
four matrix cases (linux+musl, linux+glibc, darwin, win32) plus the
win32 ia32->x86 arch normalization:
- pnpm/artifacts/exe/platform-pkg-name.js exposes `exePlatformPkgName`
(returns `@pnpm/exe.<platform>-<arch>[-musl]`). setup.js imports it
instead of inlining the logic; the new setup.test.ts block covers the
four-case matrix without having to mock detect-libc or patch
process.platform.
- engine/pm/commands/src/self-updater/installPnpm.ts exports a new
`exePlatformPkgDirName` returning `exe.<platform>-<arch>[-musl]` (the
scope-local dir). linkExePlatformBinary calls it; the new
selfUpdate.test.ts block covers the same matrix.
Both helpers are deliberately pure so the non-musl CI host can still
exercise the musl code path.
* feat!: remove managePackageManagerVersions / packageManagerStrict / packageManagerStrictVersion
These three settings existed only to derive the `onFail` behavior for
the legacy `packageManager` field. The `pmOnFail` setting introduced
in #11275 subsumes all three — it directly sets `onFail` for both
`packageManager` and `devEngines.packageManager`.
Legacy `packageManager` now defaults to `onFail: 'download'` when no
override is set. `COREPACK_ENABLE_STRICT` is no longer read (it only
gated `packageManagerStrict`); `pmOnFail` is the replacement.
Also drops pass-through `packageManagerStrict*` option fields from
cli.utils / workspace.projects-reader (they were unused) and the
unused `managePackageManagerVersions` Pick in engine.pm.commands'
`SelfUpdateCommandOptions`.
* fix: use kebab-case setting name in BAD_PM_VERSION hint
Copilot review feedback: user-facing error hints for configuration keys
conventionally use the kebab-case form that matches both the CLI flag
(`--pm-on-fail`) and the `.npmrc` key, consistent with the prior hint
text that referenced `package-manager-strict`. The `pnpm-workspace.yaml`
field (`pmOnFail`) is camelCase but that mapping is documented
elsewhere.
* Revert "fix: use kebab-case setting name in BAD_PM_VERSION hint"
This reverts commit e03c29b17. pnpm-workspace.yaml uses camelCase
(`pmOnFail`) — the primary config location for pnpm 11 — so the
hint keeps the camelCase form. The CLI flag is already shown
alongside.
When pnpm self-updates via the headless install path, the install
directory was not registered in the store's project registry. This
caused `pnpm store prune` to treat its global virtual store packages
as unreachable and remove them, breaking the global pnpm binary.
Register the install dir after headless install in installPnpmToGlobalDir
Major cleanup of the config system after migrating settings from `.npmrc` to `pnpm-workspace.yaml`.
### Config reader simplification
- Remove `checkUnknownSetting` (dead code, always `false`)
- Trim `npmConfigTypes` from ~127 to ~67 keys (remove unused npm config keys)
- Replace `rcOptions` iteration over all type keys with direct construction from defaults + auth overlay
- Remove `rcOptionsTypes` parameter from `getConfig()` and its assembly chain
### Rename `rawConfig` to `authConfig`
- `rawConfig` was a confusing mix of auth data and general settings
- Non-auth settings are already on the typed `Config` object — stop duplicating them in `rawConfig`
- Rename `rawConfig` → `authConfig` across the codebase to clarify it only contains auth/registry data from `.npmrc`
### Remove `rawConfig` from non-auth consumers
- **Lifecycle hooks**: replace `rawConfig: object` with `userAgent?: string` — only user-agent was read
- **Fetchers**: remove unused `rawConfig` from git fetcher, binary fetcher, tarball fetcher, prepare-package
- **Update command**: use `opts.production/dev/optional` instead of `rawConfig.*`
- **`pnpm init`**: accept typed init properties instead of parsing `rawConfig`
### Add `nodeDownloadMirrors` setting
- New `nodeDownloadMirrors?: Record<string, string>` on `PnpmSettings` and `Config`
- Replaces the `node-mirror:<channel>` pattern that was stored in `rawConfig`
- Configured in `pnpm-workspace.yaml`:
```yaml
nodeDownloadMirrors:
release: https://my-mirror.example.com/download/release/
```
- Remove unused `rawConfig` from deno-resolver and bun-resolver
### Refactor `pnpm config get/list`
- New `configToRecord()` builds display data from typed Config properties on the fly
- Excludes sensitive internals (`authInfos`, `sslConfigs`, etc.)
- Non-types keys (e.g., `package-extensions`) resolve through `configToRecord` instead of direct property access
- Delete `processConfig.ts` (replaced by `configToRecord.ts`)
### Pre-push hook improvement
- Add `compile-only` (`tsgo --build`) to pre-push hook to catch type errors before push
The store install path runs the bootstrap version's
linkExePlatformBinary, not the target version's. So the pn hardlink
fix only works when the bootstrap already has it. Making pn a shell
script in the tarball (via prepare.js) means it works regardless of
which version does the installing — same approach as pnpx/pnx.
* fix(exe): create pn/pnpx/pnx binaries in linkExePlatformBinary
When pnpm auto-manages its version via the `packageManager` field,
it installs @pnpm/exe to the store with scripts disabled. The
`linkExePlatformBinary` function replicates setup.js by linking the
platform binary, but it only created the `pnpm` binary.
The published @pnpm/exe tarball has placeholder files for pn, pnpx,
and pnx (written by prepare.js). Without setup.js running, these
remain as placeholders, causing "This: not found" when invoked.
Create pn (hardlink to native binary) and pnpx/pnx (shell scripts)
in linkExePlatformBinary, matching what setup.js does.
* fix(exe): remove unnecessary placeholder writes on Windows
* test(exe): verify pn/pnpx/pnx are created by linkExePlatformBinary
* test(exe): e2e test that setup.js creates all binaries after prepare.js
Runs prepare.js (simulating publish) then setup.js (simulating install)
and verifies that pnpm and pn are hardlinks to the platform binary,
and pnpx and pnx are executable shell scripts.
Also fixes setup.js to unlink before writing shell scripts, so that
the 0o755 mode is applied even when prepare.js already created the
file with 0o644.
* fix: use node: protocol for imports
* fix(exe): use shell script aliases for pn instead of hardlinks
pn, like pnpx and pnx, is now a shell script (`exec pnpm "$@"`)
instead of a hardlink to the native binary. This avoids duplicating
the ~100MB binary.
Updated in both setup.js (registry installs) and
linkExePlatformBinary (store installs via version switching).
* fix(exe): revert pn back to hardlink, keep pnpx/pnx as shell scripts
Hardlinks have zero overhead and no disk cost (shared inode).
Shell scripts are only needed for pnpx/pnx which inject the dlx arg.
* fix(exe): only ignore ENOENT in createShellScript unlink
* fix(exe): publish pnpx/pnx with real content instead of placeholders
prepare.js now writes the actual shell scripts for pnpx and pnx
(and their .cmd/.ps1 Windows wrappers) instead of placeholder text.
This means setup.js and linkExePlatformBinary only need to handle
the native binary hardlinks (pnpm, pn) and the Windows bin rewrite.
The published tarball contains the correct pnpx/pnx scripts for all
platforms, so they work even when lifecycle scripts don't run (e.g.
store installs during auto version management).
* fix(exe): skip hardlink test when platform binary is unavailable
The platform-specific packages (@pnpm/linux-x64 etc.) are optional
dependencies only available in the @pnpm/exe package, not in CI
test environments. Split the test so prepare.js content verification
always runs, while the setup.js hardlink test skips gracefully.
* style: use single quotes in test
Replace node-fetch with native undici for HTTP requests throughout pnpm.
Key changes:
- Replace node-fetch with undici's fetch() and dispatcher system
- Replace @pnpm/network.agent with a new dispatcher module in @pnpm/network.fetch
- Cache dispatchers via LRU cache keyed by connection parameters
- Handle proxies via undici ProxyAgent instead of http/https-proxy-agent
- Convert test mocking from nock to undici MockAgent where applicable
- Add minimatch@9 override to fix ESM incompatibility with brace-expansion
Previously, globally installed binaries were placed directly in
PNPM_HOME, which also contains internal directories (global/, store/).
This polluted shell autocompletion with non-executable entries.
Now binaries are stored in PNPM_HOME/bin, keeping the PATH clean.
Closes#10986