* chore: use @zkochan/git-wt package for worktree creation
Replace the in-repo `worktree:new` script and `shell/wt.*` helpers with
the published `@zkochan/git-wt` package. Contributors now install it
globally (`pnpm add -g @zkochan/git-wt`) and enable the `wt` shell
function via `git-wt init <shell>`, which also makes `git wt <branch>`
available as a native git subcommand.
* chore: remove shell/cleanup-worktrees.sh
Its functionality is now available as `git-wt cleanup` in the
@zkochan/git-wt package, which contributors are already being directed
to install in CONTRIBUTING.md.
* docs: give copy-paste install commands for the wt shell function
Previously CONTRIBUTING.md said "add this line to your config" and showed
the snippet, making contributors open the rc file themselves. Replace with
a one-liner per shell that appends to the rc file and activates `wt` in the
current session in one go.
The `--latest` flag triggers the `installSome` code path, which built
`currentBareSpecifiers` via `getAllDependenciesFromManifest()` — a
function that excluded peer dependencies. The non-`--latest` path uses
`getWantedDependencies()`, which honors `autoInstallPeers` and includes
them. Pass `autoInstallPeers` through `getAllDependenciesFromManifest`
so both paths agree.
Closes#9900
Co-authored-by: Zoltan Kochan <z@kochan.io>
Drops `getNodeExecPath`, `getNodeExecPathInBinDir`, and
`getNodeExecPathInNodeDir` along with their now-unused `which` dependency.
None of these helpers were referenced anywhere in the codebase.
* fix(binary-fetcher): skip zip directory entries during Node.js extraction
When a Node.js Windows zip contains explicit directory entries (which
real `node-vX.Y.Z-win-<arch>.zip` archives do), `extractEntryTo` for
the top-level directory recurses over every descendant via
`getEntryChildren(subfolders: true)`, writing every child file
directly and bypassing the `ignoreEntry` filter. That re-materialized
the `npm`, `npx`, and `corepack` files stripped in #11325.
Skip directory entries in the loop and let file extraction create
parent directories implicitly. Add a regression test that constructs
a zip with explicit directory entries.
Closes the regression on `installing/deps-installer/test/install/nodeRuntime.ts`
observed on Windows after #11325.
* docs: remove 'subfolders' cspell-flagged word from fix commit
* 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.
## Summary
`@pnpm/exe@11.0.0-rc.4` aborts on every invocation with:
```
node::sea::(anonymous namespace)::SeaDeserializer::Read() at ../src/node_sea.cc:174
Assertion failed: (format_value) <= (static_cast<uint8_t>(ModuleFormat::kModule))
```
Two independent Node.js v25.7+ SEA regressions are responsible, both surfaced by the rc.4 bump of the embedded runtime from 25.6.1 to 25.9.0. This PR fixes both and adds a prepublish smoke test so a broken binary can't reach npm again.
## Root cause
**1. SEA blob format changed in Node.js v25.7.0** ([nodejs/node#61813](https://github.com/nodejs/node/pull/61813) added ESM-entry-point support and inserted a new `ModuleFormat` byte into the blob header). SEA blobs carry no version marker, so a blob written by one Node.js version can only be deserialized by a matching one. In rc.4, the CI host Node.js (25.6.1, pre-change) wrote the blob and it was embedded in a 25.9.0 runtime (post-change) — the deserializer reads a misaligned byte as `format_value`, exceeds `kModule`, `CHECK_LE` fires, `SIGABRT`. `resolveBuilderBinary()` was preferring `process.execPath` whenever the running Node supported `--build-sea`, never checking that its version matched the embedded runtime.
**2. Node.js v25.7+ replaces the ambient `require` and `import()` inside a CJS SEA entry with embedder hooks** that only resolve built-in module names. The `pnpm.cjs` shim loaded `dist/pnpm.mjs` via `await import(pathToFileURL(...).href)`, which after the fix to (1) reached the CJS entry and then blew up with:
```
ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: file:///.../dist/pnpm.mjs
at loadBuiltinModuleForEmbedder
at importModuleDynamicallyForEmbedder
```
## Changes
- **`releasing/commands/src/pack-app/packApp.ts`** — `resolveBuilderBinary` now takes the resolved target runtime version and only reuses `process.execPath` when `process.version` exactly matches; otherwise it downloads a host-arch Node of the target version via the existing `ensureNodeRuntime` path. Added `PACK_APP_RUNTIME_TOO_OLD` for runtimes older than v25.5 (no `--build-sea`). Removed the now-unused `DEFAULT_BUILDER_SPEC` and the stale `fetch`/`nodeDownloadMirrors` args on the builder resolver. Help text / examples refreshed to drop `node@22` / `node@lts` references that would now be rejected.
- **`pnpm/pnpm.cjs`** — loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(fileURL)`. `createRequire` returns a regular CJS loader that bypasses the SEA embedder hooks, and the pnpm bundle has no top-level await so synchronous `require` of ESM (Node 22+) loads it cleanly. No build-time paths are baked in — `process.execPath` is evaluated at runtime, verified by relocation-testing the darwin-arm64 SEA under `/tmp/`.
- **`pnpm/artifacts/verify-binary.mjs`** (new) + `prepublishOnly` on every platform artifact — replaces the existence-only `test -f pnpm` gate with:
1. A **relocation-sensitivity check**: run the binary without `dist/` staged and confirm the failure mentions a path derived from `process.execPath`, not a build-time constant. Catches any future regression of (2).
2. A **smoke test**: stage a `dist → ../exe/dist` symlink (using `symlink-dir` so Windows junctions are handled transparently), exec `./pnpm -v`, assert the output is a SemVer 2 string.
- Cross-platform targets (darwin/win32 artifacts on a Linux CI, or a libc mismatch) skip the exec with a log line and fall back to existence-only, so a musl artifact published from a glibc host still goes through.
- Real `dist/` dirs (developer layout) are preserved; stale symlinks from aborted runs are replaced; created symlinks are cleaned up on exit.
- **`pnpm/artifacts/exe/test/setup.test.ts`** — new `pnpm -v` execution test gated on both the platform binary and the staged bundle being present, so ordinary `pn compile` test runs skip cleanly instead of failing on a missing `dist/`.
* 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.
## Summary
- pnpm installing a Node.js runtime (`node@runtime:<ver>`, `pnpm env use`, `pnpm runtime set node`) no longer extracts the bundled `npm`, `npx`, and `corepack`. These make up ~2,800 of ~5,800 files in a typical Node.js archive, so skipping them materially reduces hashing, CAS writes, SQLite index inserts, and import/link work.
- Users who still need `npm` can install it as a separate package.
## How
A new optional `ignoreFilePattern` (regex source string, serializable across the worker boundary) threads through `FetchOptions` → `tarball-fetcher` → `@pnpm/worker` → `cafs.addFilesFromTarball`. `cafs.addFilesFromTarball` now accepts a per-call ignore on top of the existing cafs-level `ignoreFile`; the two are combined.
`@pnpm/fetching.binary-fetcher` defines the Node-specific regex and applies it when `opts.pkg.name === 'node'`:
- Tarball path: sets `ignoreFilePattern`.
- Windows zip path: new `ignoreEntry?: RegExp` on `AssetInfo`; `extractZipToTarget` strips the `basename/` prefix and skips matching entries before `zip.extractEntryTo`.
`@pnpm/engine.runtime.node-resolver`'s `getNodeBinsForCurrentOS` drops `npm`/`npx` so pnpm no longer creates shims for bins that no longer exist.
## Breaking change
Shipping in v11. After this lands, `pnpm runtime set node` / `node@runtime:<version>` no longer puts `npm`, `npx`, or `corepack` on `$PATH`. Scripts that call them directly will need to install npm separately.
## Summary
- **@pnpm/exe build tooling**: Replace the 253-line hand-rolled SEA builder in `pnpm/artifacts/exe/scripts/build-artifacts.ts` with a thin ~60-line driver that delegates to the new `pnpm pack-app` command (shipped in #11312). Per-platform defaults (entry, outputDir, outputName, targets) move to `pnpm.app` in `pnpm/artifacts/exe/package.json`.
- **pack-app CLI (breaking, unreleased)**: Rename `--node-version <v>` → `--runtime <name>@<v>`, and `pnpm.app.nodeVersion` → `pnpm.app.runtime`. Only `node@<v>` is accepted today; the `<name>@` prefix reserves room for `bun` / `deno` without a future breaking change.
- **Embedded Node**: Pin `node@25.9.0` in the build-artifacts driver.
## Why the rename
pack-app's old `--node-version` flag silently inherited from pnpm's global `node-version` rc setting (which controls which Node pnpm uses to run user scripts). That value leaked into `Config['nodeVersion']` and overrode `pnpm.app.nodeVersion`, causing the wrong Node build to be embedded in SEAs for users who had the rc key set — reproduced locally, where `linux-arm64-musl` failed entirely because Node 22.13.0 has no arm64-musl variant. `runtime` doesn't collide with any Config field, so CLI / `pnpm.app` / running-Node precedence now behaves as intended.
## Behavior parity with the old script
- Same output paths: `pnpm/artifacts/<target>/pnpm` (or `pnpm.exe` for `win32-*`).
- Same signing (pack-app uses `codesign` natively on macOS, `ldid` when cross-signing from Linux).
- Same host-based target narrowing: Linux/M1 Mac build the full 8-target matrix; Intel Mac builds only `win32-x64`, `linux-x64`, `darwin-x64`.
- Same `dist/` copy + `@reflink/*` prune into `pnpm/artifacts/exe/dist/` for npm publishing.
- Same bundle-first step (`pn --filter=pnpm prepublishOnly &&` prefix is unchanged in the `build-artifacts` npm script).
Library packages had `prepublishOnly: pn compile`, which expands to
`tsgo --build && pn lint --fix`. During `pn release` that runs eslint
against ~150 packages for no benefit — the code has already been linted
in CI and the release flow's upfront compile has already built dist/.
Switch lib prepublishOnly to a bare `tsgo --build` so the safety-net
compile stays but the per-package eslint cost is gone.
The rename in 5a293d250c changed targets from `win-x64`/`win-arm64` to
`win32-x64`/`win32-arm64`, but the `.exe`-suffix check in `build()` still
matched `win-`, so Windows SEAs got built as `pnpm` instead of `pnpm.exe`.
That broke both `@pnpm/exe.win32-*`'s `prepublishOnly` check and
`copy-artifacts.ts`'s Windows zip creation (which looks for `pnpm.exe`).
`path.join(lockfileDir, resolution.directory)` mangles absolute cross-drive
Windows paths by literally concatenating them (`path.join('D:\\foo',
'C:\\bar')` → `'D:\\foo\\C:\\bar'`). Switch to `path.resolve` so stored
absolute paths are used as-is.
This surfaced as an ENOENT during `pnpm setup` in CI when `PNPM_HOME` and
the OS temp dir (containing the extracted v11 tarball that setup installs
via `pnpm add -g file:<dir>`) were on different drives.
* 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.
* fix: give each runtime variant its own global virtual store entry
When a runtime package (e.g. node@runtime:X.Y.Z) uses a variations
resolution, createFullPkgId() in @pnpm/deps.graph-hasher was hashing
the whole VariationsResolution — the same hash on every host — so the
global virtual store path collided between variants. Whichever variant
installed first won, and a later `pnpm add --libc=musl node@runtime:<v>`
silently reused the cached glibc (or macOS/Windows) binary.
The fix threads supportedArchitectures down to createFullPkgId so the
selected variant's integrity is used as the package fingerprint. Two
related cleanups land with it:
- Extract the platform-variant selection logic to @pnpm/resolving.resolver-base
as selectPlatformVariant/resolvePlatformSelector. The helper's libc
match also required a fix: a variant with no libc is the "default"
build, and a request for a non-default libc (e.g. musl) must require
an exact match so the default variant doesn't silently win.
- @pnpm/installing.package-requester's findResolution now delegates to
the shared helper, and the new supportedArchitectures param is plumbed
through calcDepState / calcGraphNodeHash / iterateHashedGraphNodes /
lockfileToDepGraph and their callers in deps-resolver, deps-restorer,
deps-installer, graph-builder, and building.after-install.
* feat: add pnpm build-sea command for building Node.js SEA executables
Adds `pnpm build-sea` under @pnpm/releasing.commands. Takes a CommonJS
entry file and a set of target triplets (linux-x64, linux-x64-musl,
linux-arm64, linux-arm64-musl, macos-x64, macos-arm64, win-x64,
win-arm64) and produces a standalone executable per target under
dist-sea/<target>/.
Each target's Node.js runtime is fetched via `pnpm add node@runtime:<v>
--os=<os> --cpu=<arch> --libc=<libc>` into $PNPM_HOME/build-sea/<target>-<v>/
so binaries are hardlinked from the global content-addressable store and
`pnpm store prune` can reclaim them.
Requires Node.js v25.5+ to perform the --build-sea injection. If the
running Node is older, a v25 binary is downloaded and used as the builder
automatically. macOS outputs are ad-hoc signed with codesign (on macOS)
or ldid (when cross-compiling from Linux), which is required because SEA
injection invalidates the binary's existing signature.
* fix(build-sea): reject malformed --target, --output-name and use mkdtemp for config
Addresses Copilot review feedback on the build-sea command:
- parseTarget() previously destructured the target string, silently
accepting extra `-` segments. Inputs like `linux-x64-musl-../../outside`
would pass validation and flow into path.join. Validation is now done
with a strict anchored regex.
- --output-name was passed into path.join() without sanitization, so a
caller could escape the output directory with path separators or `..`.
validateOutputName() now rejects anything that isn't a plain basename.
- The per-target SEA config file was written to a predictable path under
os.tmpdir() (derived from the target name and Date.now()), which is
unsafe on multi-user systems. It now lives inside a fresh mkdtemp()
directory and is opened with the exclusive "wx" flag.
- New test cases cover extra-segment targets, uppercase/whitespace
variants, and the full matrix of invalid --output-name inputs.
* rename: build-sea → pack-app
`build-sea` required knowing what a SEA is. `pack-app` is self-describing,
doesn't collide with pnpm's existing `bin` concept, and parallels the
existing `pack` command.
- Command name: build-sea → pack-app
- Default output dir: dist-sea → dist-app
- Error codes: PACK_APP_* (was BUILD_SEA_*)
- Export/type: packApp / PackAppOptions (was buildSea / BuildSeaOptions)
- Install cache dir: $PNPM_HOME/pack-app (was $PNPM_HOME/build-sea)
The Node.js `--build-sea` flag name itself is unchanged — that's a
Node.js feature and outside this project's naming.
* fix(pack-app): reject directory entries, pin builder to >=25.5, refuse macOS target on Windows
Addresses Copilot review feedback on the pack-app command:
- entry validation now rejects non-file paths (directories, symlinks to
non-files) with a dedicated PACK_APP_ENTRY_NOT_FILE instead of
surfacing a less actionable error later in the SEA build.
- DEFAULT_BUILDER_SPEC was the bare major ("25"), which would satisfy
with 25.0.x if that version is still present — those point releases
predate --build-sea support. Tightened to ">=25.5.0 <26.0.0" so the
download is guaranteed to support the flag without ever crossing a
major.
- adHocSignMacBinary() silently skipped re-signing on Windows hosts.
Now throws PACK_APP_MACOS_SIGN_UNSUPPORTED_HOST with a hint to build
the target on macOS/Linux or re-sign manually.
- resolvePlatformSelector() JSDoc now matches what the code actually
does (picks the first entry when it is not "current"; later entries
are ignored).
- New test case covers the directory-as-entry rejection.
* refactor(pack-app): switch target OS names to process.platform constants
Previously `pack-app` accepted `macos-*` / `win-*` as the OS portion of a
target triplet and translated them to `darwin` / `win32` internally. The
translation layer made the CLI surface inconsistent with the values that
`pnpm add --os=…` and `supportedArchitectures.os` already use, and added
a small footgun (e.g. users setting `supportedArchitectures: { os: [darwin] }`
but typing `macos-arm64` for pack-app).
The supported target OS set is now `linux | darwin | win32`, matching
`process.platform`. Old inputs like `macos-arm64` or `win-x64` now fail
validation with a clear error pointing to the new naming. The internal
parseTarget helper drops its TARGET_OS_MAP lookup entirely.
This is a change to an unreleased command so there is no back-compat
concern. pnpm's own artifact directory names (`pnpm/artifacts/macos-*/`,
`pnpm/artifacts/win-*/`) are an internal implementation detail and are
not affected by this change.
* feat(pack-app): read defaults from pnpm.app in package.json
Every pack-app flag (--entry, --target, --node-version, --output-dir,
--output-name) can now be preconfigured in the project's package.json
under a new "pnpm.app" object:
{
"name": "my-cli",
"pnpm": {
"app": {
"entry": "dist/index.cjs",
"targets": ["linux-x64", "darwin-arm64", "win32-x64"],
"nodeVersion": "25",
"outputDir": "release",
"outputName": "my-cli"
}
}
}
CLI flags always win. --target replaces the configured list rather than
appending, so a user can narrow the default set at the command line.
The config loader is strict: unknown keys under pnpm.app and any
type-mismatched values throw PACK_APP_INVALID_CONFIG so mistakes surface
at invocation time instead of silently being ignored.
Chose pnpm.app over pnpm.packApp because it's the shorter, cleaner
namespace for anything related to the app bundle (future sibling
commands like run-app / deploy-app could share the same object without
a naming clash). Chose package.json over pnpm-workspace.yaml because
the config is inherently per-project, whereas pnpm-workspace.yaml is
workspace-root-only.
* fix(pack-app): deterministic libc selection and stricter output-name validation
Addresses Copilot review feedback:
- ensureNodeRuntime() now always passes an explicit --libc for linux
targets. Without a suffix, linux-x64 and linux-arm64 default to
--libc=glibc instead of letting the user's supportedArchitectures.libc
config or the host's detected libc decide the variant. The install
cache directory mirrors this, so glibc and musl variants are always
distinct (linux-x64-glibc vs linux-x64-musl).
- resolveBuilderBinary() now pins the host libc when downloading a
builder Node on Linux. A user whose config sets supportedArchitectures.libc
to musl no longer ends up with a musl Node that the glibc host cannot
execute.
- validateOutputName() rejects Windows-invalid filename characters
(<>:"|?* and NUL), Windows reserved device names (CON, NUL, COM1, etc.),
and names ending in a dot or space — problems surface at invocation
time rather than during writeFile(outputFile, ...) on Windows.
- lockfileToDepGraph variants tests no longer derive the "host"
variant from process.platform/process.arch; they always pass an
explicit supportedArchitectures selector so the expectations hold on
any CI host (including Alpine/musl).
* chore: add "toctou" to cspell wordlist
`TOCTOU` (time-of-check-to-time-of-use) is the standard term for the
race-condition class the pack-app SEA-config comment describes. Adding
it to the wordlist unblocks the Lint CI step.
* fix: lint
* fix(sbom): resolve licenses for git-sourced dependencies
`readPackageFileMap` did not handle `type: 'git'` resolutions, causing
`pnpm sbom` to emit NOASSERTION and `pnpm licenses` to throw for any
dependency installed from a git URL.
Closes#11260
* fix: add missing store.cafs devDep, test tsconfigs, and size field
- Add @pnpm/store.cafs devDependency and tsconfig reference to
license-scanner so CI typecheck resolves the PackageFilesIndex import
- Add test/tsconfig.json to pkg-finder so CI typechecks the new tests
- Add required `size` field to PackageFileInfo test fixtures
* fix: replace spellcheck-failing test strings
* fix: use spellcheck-safe integrity string in test
* style: fix import sort in pkg-finder test
* fix(sbom): use packageIdFromSnapshot to match store index keys
The SBOM used `snapshot.id ?? depPath` as the package ID, which
includes the package name prefix (e.g. `left-pad@git+https://...`).
The store index stores git packages under just the git URL without
the name prefix. Use `packageIdFromSnapshot` which strips the prefix,
matching how the licenses command already does it.
Also fixes test store keys to match the real installer layout so the
mismatch would have been caught by tests.
* refactor: move git resolution check after tarball check
Tarball resolutions are more common than type: 'git', so check them
first. Per review feedback from @zkochan.
The sveltejs/action-deploy-docs repository was deleted from GitHub, causing
CI failures in tests that fetched it. Replaces the reference with
pnpm-e2e/drupal-js-build, which is owned by the pnpm org and is large
enough to cover the regression from #4064.
## Summary
Adds an opt-in **pnpm agent** server that resolves dependencies server-side and streams only the files missing from the client's content-addressable store.
- **`@pnpm/agent.server`** — multi-process HTTP server (Node.js `cluster`) with SQLite-backed metadata and file caches
- **`@pnpm/agent.client`** — streams an NDJSON response, dispatches worker threads to fetch files while the server is still resolving
- **New config**: `agent` in `pnpm-workspace.yaml` (opt-in)
## How it works
1. Client reads integrity hashes from its local store index
2. Sends `POST /v1/install` with dependencies + store integrities
3. Server resolves the dependency tree using pnpm's `install({ lockfileOnly: true })`, with a SQLite-backed `PackageMetaCache` for fast repeat resolution
4. As each package resolves, a wrapped `storeController.requestPackage` looks up its files and immediately streams digests the client is missing (NDJSON `D` lines)
5. Client reads the stream line by line; digest batches fill up and dispatch worker threads to `POST /v1/files` — file downloads overlap with server-side resolution
6. After resolution, server sends index entries (`I` lines) and lockfile (`L` line)
7. Client writes index entries to store, then runs headless install with a wrapped `fetchPackage` that calls `readPkgFromCafs` with `verifyStoreIntegrity: false` (files are trusted from the agent)
8. `/v1/files` response is gzip-streamed (274MB → ~80MB) — server pipes through `createGzip`, worker pipes through `createGunzip`, parsing and writing files to CAFS as data arrives
## Performance
1351-package project, cold local store, warm server (localhost):
| Scenario | Time |
|----------|------|
| Vanilla pnpm install (cold OS cache) | ~48s |
| Vanilla pnpm install (warm OS cache) | ~34s |
| With pnpm agent (consistent) | **~33s** |
### Key optimizations
1. **SQLite metadata cache** — server-side resolution drops from ~3.4s to ~0.9s
2. **SQLite file store** — consistent read performance regardless of OS file cache state
3. **Streaming `/v1/install`** — file digests stream during resolution, downloads start before resolution finishes
4. **Gzip-streamed `/v1/files`** — whole-stream gzip (274MB → ~80MB), significant savings on remote servers
5. **Worker-thread streaming HTTP** — workers pipe gzip → parse → write to CAFS as data arrives, no buffering
6. **No rehashing** — server-provided digests used directly, skipping 33K SHA-512 computations
7. **No re-verification** — wrapped `fetchPackage` calls `readPkgFromCafs` with `verifyStoreIntegrity: false`
8. **Direct `writeFileSync` with `wx`** — no stat + temp + rename
9. **Pre-packed msgpack** — server sends raw store index buffers, client writes directly to SQLite
10. **WAL checkpoint** — ensures store index entries written by agent are visible to headless install's worker threads
## Usage
Start the server:
```bash
node agent/server/lib/bin.js
```
Configure in `pnpm-workspace.yaml`:
```yaml
agent: http://localhost:4873
```
- `pnpm sbom` now recognizes the deprecated `licenses` array and falls back to scanning on-disk `LICENSE` files, matching the resolution already used by `pnpm licenses`. Fixes packages like `busboy`, `streamsearch`, `limiter` being reported as `NOASSERTION`.
- Root component scans the project directory for LICENSE files too, matching transitive-dep behavior.
- Shared license resolution lives in a new `@pnpm/deps.compliance.license-resolver` package, with `parseLicenseFromManifest`, `resolveLicense` (store-file-map), and `resolveLicenseFromDir` (on-disk scan).
- LICENSE-file-detected names are filtered against `SPDX_LICENSE_IDS` before being emitted to SBOM — long-form names like "Eclipse Public License 1.0" fall through to `NOASSERTION` rather than producing non-compliant SPDX. Manifest-declared licenses pass through untouched.
- When both `license` and `licenses` are set, the modern `license` wins (previously `pnpm licenses` preferred `licenses`).
- Close#11248.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
- Suppress the `Cannot use both "packageManager" and "devEngines.packageManager" in package.json. "packageManager" will be ignored` warning only when both fields specify the exact same package manager name and the exact same version string. Any other divergence (different name, range vs. exact version, prefixed versions like `v1.2.3`, etc.) still warns.
- Lets projects keep both fields during migration (e.g. so v10 installs still auto-switch via `packageManager`, while v11 uses `devEngines.packageManager` and `npm install` still errors) without a noisy warning — as long as the two values are kept in sync.
Closes#11301
* fix: support explicit versions and --no-commit-hooks / --no-git-tag-version in `pnpm version`
Registers options under their canonical names so nopt correctly parses the
`--no-*` variants, accepts an explicit semver argument alongside bump types,
and creates a git commit + annotated tag for the bump (honoring
`--no-git-tag-version`, `--no-commit-hooks`, `--sign-git-tag`, `--message`,
and `--tag-version-prefix`). Also fixes `--no-git-checks` which was parsed
incorrectly. Closes#11271.
* chore: add gpgsign and newversion to cspell; set gpg sign config in version test
* fix: address Copilot review feedback on pnpm version
* fix: skip git commit and tag in recursive version runs
* fix: address Copilot review feedback on pnpm version
* feat: publish base docker image to GHCR
Adds a Dockerfile (debian:stable-slim + pnpm standalone binary) and a
release-triggered workflow that builds multi-arch images and pushes to
ghcr.io/pnpm/pnpm. Users who need Node.js can install it inside the
container via `pnpm runtime set node <version>`.
Refs #11300
* docs: add docker/README.md
* chore(cspell): add buildx to dictionary
* docs: mention devEngines.runtime as alternative to pnpm runtime set
* fix(docker): pin base image, verify tarball sha256, harden download
- Pin `debian:stable-slim` to a digest for reproducibility.
- Compute pnpm tarball SHA256 in the workflow and verify it inside the
build, detecting tampered artifacts regardless of what `pnpm --version`
reports.
- Download the tarball to disk with `--retry` instead of `curl | tar`
for resilience under multi-arch QEMU builds.
- README: use `--load` so the local test image is available to `docker run`.
* chore(cspell): sort dictionary additions
* fix(docker): address Copilot review feedback
- Include $PNPM_HOME/bin on PATH so pnpm-installed globals (node, etc.)
are discoverable, and make $PNPM_HOME writable for non-root users.
- Document that `pnpm runtime set node` needs `-g` to install globally.
- Pass workflow inputs via env: instead of inlining GitHub expressions
into shell, and validate the version string before use.
* fix(docker): install libatomic1 for pnpm standalone binary
The pnpm linux standalone binary dynamically links against
libatomic.so.1, which is not present in debian:stable-slim by
default. Without it, `pnpm --version` fails during the build with:
pnpm: error while loading shared libraries: libatomic.so.1:
cannot open shared object file: No such file or directory
Caught by local build testing.
* fix: preserve pnpm 10 peer suffix encoding for linked paths
The filenamify upgrade from v4 to v7 changed the peer-suffix "version"
token for linked dependency paths: `../packages/b` became `+packages+b`
instead of `packages+b`, causing lockfile churn for workspaces with
packages linked from outside the workspace root.
Replace the filenamify call with a small inline encoder that reproduces
v4's output for link paths, and drop the now-unused dependency.
Closes#11272.
* chore: avoid cspell-flagged word in peer suffix comment
* test: cover linkPathToPeerVersion and clarify its lossy encoding
Address Copilot review feedback on #11297:
- Correct the comment — any leading run of `.` is dropped, not just
`./` and `../` segments (so `.hidden/pkg` becomes `hidden+pkg`).
- Export the helper and add a focused test that pins the exact token
output for link paths, so future lockfile-breaking regressions get
caught by the test suite.
* refactor: extract linkPathToPeerVersion into its own file
In --global mode, globalAdd passes workspaceDir to approve-builds so it can
update the global pnpm-workspace.yaml. approve-builds then forwarded that
workspaceDir into install.handler, which (with workspacePackagePatterns
undefined) recursively discovered sibling install dirs as workspace projects
and failed the frozen-lockfile check on stale @pnpm/exe install dirs.
Skips the minimumReleaseAge maturity check when the registry metadata
lacks the "time" field, instead of throwing ERR_PNPM_MISSING_TIME.
Defaults to true, and prints a warning once per affected package.
* feat: skip lockfile writes for legacy packageManager field
When pnpm is pinned via the `packageManager` field in `package.json`, the
resolved pnpm integrity info is no longer written to `pnpm-lock.yaml`
unless the pinned version is pnpm v12 or newer. `devEngines.packageManager`
still populates and reuses `packageManagerDependencies` as before. This
keeps the v10 -> v11 transition quiet by avoiding unrelated lockfile
churn for projects that pin pnpm the legacy way.
* fix: address Copilot review and CI failure
- Update `configurationalDependencies.test.ts` to assert the new behavior:
the `packageManager` field no longer writes pnpm resolution info to the
env lockfile while config dependencies still are.
- Fast-path in `switchCliVersion`: when the lockfile is not persisted and
the running CLI already matches `pm.version`, skip store access and
integrity resolution entirely.
- Clarify the `resolvePackageManagerIntegrities` docstring to describe
the conditional `save` behavior.
* test: add unit tests for shouldPersistLockfile
Extract the decision logic for persisting pnpm resolution info to the env
lockfile into a dedicated helper so the branches — devEngines source,
legacy `packageManager` field with v11 or older, v12+, and invalid/missing
version — can all be covered without needing an actual pnpm v12 tarball
on the registry.
* feat: pnpm init writes devEngines.packageManager field
Replaces the flat "packageManager" field with a "devEngines.packageManager"
entry, using a caret range and onFail: "download" so the declared pnpm
version auto-updates on install.
* fix: update workspace/commands/src/init.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* feat: add runtimeOnFail setting
Adds a `runtimeOnFail` config setting ('ignore' | 'warn' | 'error' |
'download') that overrides the `onFail` field on `devEngines.runtime`
and `engines.runtime` in the root project's package.json. This makes
it possible to opt into (or out of) runtime auto-download without
changing the project manifest.
* fix: skip runtime download when version is missing
Without a version, convertEnginesRuntimeToDependencies would write
`runtime:undefined` into the manifest. Warn and skip instead.
* feat: apply runtimeOnFail override during install
The config reader override only mutates the context's rootProjectManifest,
but installDeps reads the manifest fresh via tryReadProjectManifest and
findWorkspaceProjects. Apply the override there too so `runtimeOnFail`
actually affects what gets installed. Adds an e2e test covering both
download and ignore overrides through the real CLI bundle.
This PR implements native pnpm commands for starring and unstarring packages, listing stars, and finding the current user (whoami). It follows the project standards and provides fallbacks for various registry API versions.
* 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.
* feat(config): make dlx inherit security and trust policy settings from local config
Previously, `pnpm dlx` and `pnpm create` only inherited auth/registry
settings from the local project config, ignoring all other settings.
This meant security policy settings like `minimumReleaseAge` and
`trustPolicy` configured in a project's `pnpm-workspace.yaml` were
silently dropped.
Now these commands inherit two categories of local settings:
1. Registry & auth (existing) — needed to reach the same package sources
2. Security & trust policy (new) — settings that gate what is allowed
to be downloaded, reflecting the org's security posture
Project-structural settings (hoisting, linking, workspace layout, etc.)
remain correctly excluded.
Closes#11183https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): rename auth.ts to localConfig.ts and clean up tests
Addresses review feedback:
- Rename auth.ts / auth.test.ts to localConfig.ts / localConfig.test.ts
to reflect the broader scope (auth + security/trust policy + npmrc utils)
- Remove unnecessary `as any` casts from tests; the types already work
- Consolidate individual expect() assertions into toMatchObject
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(config): sort imports and exports after rename
Fixes simple-import-sort/imports and simple-import-sort/exports lint
errors introduced when localConfig.js replaced auth.js; the previous
position was correct for auth.* but not for localConfig.*.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): remove dead RAW_POLICY_CFG_KEYS handling
Policy keys (minimum-release-age*, trust-policy*) are filtered out of
.npmrc by isNpmrcReadableKey, so they can never appear in authConfig.
The RAW_POLICY_CFG_KEYS / isRawPolicyCfgKey / pickRawDlxConfig branch
for those keys was unreachable in production.
inheritDlxConfig now uses pickRawAuthConfig directly for the raw config
pick. The test assertion that placed minimum-release-age in authConfig
(an impossible state) is also dropped.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* test(dlx): respect minimumReleaseAge from pnpm-workspace.yaml
Integration test for #11183 — verifies that pnpm dlx, invoked via the
bundled CLI, picks up minimumReleaseAge from the project's
pnpm-workspace.yaml and rejects packages that don't meet the cutoff.
Uses the public npm registry (matching the existing minimumReleaseAge
tests in exec/commands/test/dlx.e2e.ts:391) because verdaccio includes
the 'time' field in abbreviated metadata, which short-circuits the
publish-date check.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(test): allow pnpm-workspace.yaml to override minimumReleaseAge in tests
The execPnpmSync test helper hardcoded
pnpm_config_minimum_release_age: '0'
which forced the value via env var (highest priority) for every test,
overriding any minimumReleaseAge set via pnpm-workspace.yaml.
This was inconsistent with the other settings in the helper (registry,
hoist, storeDir, fetchRetries) which use a `fallback()` reading from
the workspace manifest if present and falling back to a default
otherwise. Apply the same pattern for minimumReleaseAge.
Restores the integration test added in 6bc965b — without this fix the
test passes through dlx without applying the workspace's
minimumReleaseAge, making it not fail as the test expected.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config,test): address review feedback
localConfig.ts doc comment:
- Drop redundant "(camelCase, from Config type)" parenthetical
- Replace em-dash-sandwiched paragraph with two flat sentences
- Switch list-item em dashes to colons (label: definition form)
pnpm/test/dlx.ts:
- Switch em dash in registry-override comment to colon
- Group the minimumReleaseAge tests into a describe block
- Add positive test: dlx succeeds when the pinned version is older
than the computed minimumReleaseAge cutoff
- Add range-resolution test: dlx resolves `shx@0.3.x` to 0.3.2 when
the cutoff is positioned between 0.3.2 (2018-07-11) and 0.3.3
(2020-10-26). The ~2.3 year gap leaves ample room for CI variance;
0.3.2's publish date is hardcoded (npm policy forbids unpublishing
past 72h).
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(test,config): address Copilot review feedback
- execPnpm.ts: only set pnpm_config_minimum_release_age env var when
the workspace manifest does not specify minimumReleaseAge, so tests
that verify dlx's local-config inheritance exercise the real config
path instead of being masked by the env var
- dlx.ts: fix "~19 years" comment to "~27.4 years" (10,000 days)
- dlx.ts: add pnpm create test verifying minimumReleaseAge from
pnpm-workspace.yaml (create delegates to dlx internally)
- changeset: bump @pnpm/config.reader to major (the rename of
ignoreNonAuthSettingsFromLocal → onlyInheritDlxSettingsFromLocal
is a breaking change to the published getConfig API)
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): add noDefaultMinimumReleaseAge option to execPnpmSync
Replace the implicit workspace-yaml auto-detection with an explicit
opt-in flag. Tests that verify dlx/create inherits minimumReleaseAge
from pnpm-workspace.yaml pass `noDefaultMinimumReleaseAge: true` so
the env var default doesn't mask the real inheritance path.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): use omitEnvDefaults instead of noDefaultMinimumReleaseAge
Replace the single-purpose boolean flag with a general-purpose
`omitEnvDefaults: string[]` option on ExecPnpmSyncOpts. Tests pass the
env var name(s) to skip, e.g.
`omitEnvDefaults: ['pnpm_config_minimum_release_age']`.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): type omitEnvDefaults as PnpmEnvDefault[] literal union
Provides autocomplete and prevents typos by constraining the array
to known pnpm_config_* env var names set by the test helper.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): make omitEnvDefaults honor all listed env var names
Previously the code only checked for 'pnpm_config_minimum_release_age',
but the PnpmEnvDefault type listed 7 names, making the option silently
ineffective for the other 6. Now all defaults are set unconditionally
and any listed in omitEnvDefaults are deleted after, so every member
of PnpmEnvDefault actually works.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* docs(config): remove 'proxies' from inherited-settings examples
dlx does not actually inherit proxy settings (httpProxy / httpsProxy
etc. are neither in AUTH_CFG_KEYS nor RAW_AUTH_CFG_KEYS). The doc
comment in localConfig.ts listed 'proxies' as an example, which
mismatched the code. Drop the mention.
Behavior is unchanged; this is a docs-only fix.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(dlx): fetch full metadata when minimumReleaseAge is set
Including minimumReleaseAge in the fullMetadata condition (alongside
the existing resolution-mode=time-based and trustPolicy=no-downgrade
triggers) bypasses the abbreviated→full metadata upgrade path in
pickPackage.ts for this case. That upgrade path is fragile on Windows:
the integration test at pnpm/test/dlx.ts:112 was failing with
ERR_PNPM_MISSING_TIME only on windows-latest runners, even though
the registry response is identical across platforms.
When minimumReleaseAge is set, pnpm always needs per-version
timestamps to decide which versions are mature enough. The original
condition only handled the two other time-dependent features
(resolution-mode=time-based and trust-policy=no-downgrade), missing
minimumReleaseAge. Adding it here eliminates an unnecessary round
trip plus the flaky upgrade, and matches the intent of the existing
siblings in the condition.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* style(test): avoid 'verdaccio: verdaccio' repetition in test comment
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): rename POLICY_CFG_KEYS to SECURITY_POLICY_CFG_KEYS
'POLICY_CFG_KEYS' was too vague — reading it cold didn't convey what
kind of policy. Renamed to match the doc comment's 'security policy'
wording. Also renamed 'isPolicyCfgKey' → 'isSecurityPolicyCfgKey'.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* test(config): drop impossible 'cache-dir' key from inheritAuthConfig test
Addressing @zkochan's review: 'cache-dir' can never appear in
authConfig in production (pickIniConfig filters it out at .npmrc
load), so the assertion was testing an impossible state. Removed
from both the target's authConfig and the expected assertion.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
---------
Co-authored-by: Claude <noreply@anthropic.com>
## Summary
- **New command `pnpm with <version|current> <args...>`** — runs pnpm at a specific version (or the currently active one) for a single invocation, bypassing the project's `packageManager` and `devEngines.packageManager` pins. Uses the same install mechanism as `pnpm self-update`, caching the downloaded pnpm in the global virtual store for reuse.
- **New config setting `pmOnFail`** — overrides the `onFail` behavior of both `packageManager` and `devEngines.packageManager`. Accepted values: `download`, `error`, `warn`, `ignore`. Readable from CLI flag, env var, `pnpm-workspace.yaml`, or `.npmrc` — useful when version management is handled by an external tool (asdf, mise, Volta, etc.) and the project wants pnpm itself to skip the check.
```
pnpm with current install # one-shot, use running pnpm
pnpm with 11.0.0-rc.1 install # one-shot, use specific version
pnpm install --pm-on-fail=ignore # direct CLI flag
pnpm install --config.pm-on-fail=ignore # equivalent via --config.* sugar
pnpm_config_pm_on_fail=ignore pnpm install # env var
# or in pnpm-workspace.yaml: pmOnFail: ignore
```
## Implementation notes
- Command handler lives in `@pnpm/engine.pm.commands` (next to `self-update` and `setup`).
- `'with'` added to `SPECIALLY_ESCAPED_CMDS` in `cli/parse-cli-args` so args after `<spec>` pass through opaquely like `dlx`/`run`.
- `pnpm with current <cmd> [args]` is rewritten in `pnpm/src/parseCliArgs.ts` to an in-process dispatch — argv is rebuilt in place so any global flags the user put before `with` (e.g. `--dir`, `--filter`) are preserved. `process.env.pnpm_config_pm_on_fail=ignore` is set so the override survives `parseCliArgsLib`'s `-v` / `--help` short-circuits (which discard other parsed options).
- `main.ts` treats `skipPackageManagerCheck: true` as bypassing both the auto-download and the warn/error check (previously only the check). Also skips when `cmd='help'` and the help target is itself a skip-check command, so `pnpm with -h` works in pinned projects without downloading the pinned version first.
- Errors reported to stderr for `with` (aligned with `dlx`/`create`/`sbom`).
- `pmOnFail` wired in `config/reader/src/index.ts`: added to `types`, `Config`, and `pnpmConfigFileKeys`; applied as an override in the `onFail` resolution block.
- The `with <version>` child process sets both `COREPACK_ROOT` (honored by every pnpm release via `isExecutedByCorepack()`) and `pnpm_config_pm_on_fail=ignore` (principled override on new releases that ship the setting). This gives graceful behavior when `pnpm with 9.3.0 install` spawns an older pnpm that predates the new setting.
- Store controller lifecycle in the handler wrapped in `try/finally` to prevent leaks on install errors. Signal-induced child exits return a non-zero exit code so interrupted runs aren't masked as success.
The legacy `/-/npm/v1/security/audits{,/quick}` endpoints have been retired by npmjs.org. This PR rewires the audit client to the replacement `/-/npm/v1/security/advisories/bulk` endpoint.
The new endpoint is not a drop-in rename — the request and response contracts are both different:
- **Request**: a flat `{ pkgName: [versions] }` map. `lockfileToAuditRequest` walks the lockfile once and builds the POST body directly; there is no more nested `AuditTree`.
- **Response**: only `id`, `url`, `title`, `severity`, `vulnerable_versions`, and `cwe` per advisory. Everything else the old endpoint returned is computed locally:
- `findings[].paths` are walked from the lockfile (skipped entirely when the response is empty; the second walk intentionally avoids `@pnpm/lockfile.walker`'s global dedup so alternate install chains to the same shared dep aren't dropped).
- `metadata.vulnerabilities` counts advisories per severity.
- `metadata.dependencies` / `devDependencies` / `optionalDependencies` / `totalDependencies` come from a classified lockfile walk; the classifier respects `--prod`/`--dev` include flags when deciding whether a subgraph is reachable non-optionally.
- `patched_versions` is inferred from the vulnerable range for common `<X.Y.Z` / `<=X.Y.Z` shapes so `audit --fix` can still produce usable overrides; left `undefined` when inference fails.
- `github_advisory_id` is parsed from the advisory URL and canonicalized to the github.com form (uppercase `GHSA-` prefix, lowercase suffix).
- `info` severity is now supported end-to-end (severity type, `--audit-level`, filters, colors).
## Breaking changes (v11)
- Private registries that do not implement `/advisories/bulk` now fail with `AuditEndpointNotExistsError`.
- CVE-based filtering is replaced with GHSA-based filtering, since the bulk endpoint does not return CVE identifiers:
- `auditConfig.ignoreCves` → `auditConfig.ignoreGhsas` (the old key is no longer recognized).
- `pnpm audit --ignore <id>` and `--ignore-unfixable` now read and write GHSAs.
- Migration: replace each `CVE-YYYY-NNNNN` in `auditConfig.ignoreCves` with the matching `GHSA-xxxx-xxxx-xxxx` (visible in the `More info` column of `pnpm audit` output) under `auditConfig.ignoreGhsas`.
- `--ignore-unfixable` now only targets advisories whose patched range couldn't be inferred — the only "no fix available" signal the bulk endpoint provides.
- `AuditReport` and `AuditAdvisory` are trimmed to just the fields the audit client actually populates:
- `AuditReport`: `advisories` + `metadata` only (`actions` and `muted` removed).
- `AuditAdvisory`: `findings`, `id`, `title`, `module_name`, `vulnerable_versions`, `patched_versions?`, `severity`, `cwe`, `github_advisory_id`, `url`. Dropped: `cves`, `created`, `updated`, `deleted`, `access`, `overview`, `recommendation`, `references`, `found_by`, `reported_by`, `metadata`.
- `AuditAction`, `AuditResolution`, `AuditActionRecommendation` removed (no consumers).
## Hardening
- Response body validated: non-object / malformed JSON / non-array package buckets all surface as `ERR_PNPM_AUDIT_BAD_RESPONSE` with a body excerpt. Advisory `id` must be a finite number and `severity` must be a known value before being indexed.
- Name-keyed records use `Object.create(null)` so a hostile/unusual package name can't trigger prototype pollution.
- GHSA ids canonicalized on both read and write so casing drift between config and registry doesn't mask ignores.
- `findings[].paths` are deduped and capped per (name, version) to keep pathologically shared graphs from blowing up memory.
## Internals
- `AuditTree` / `AuditNode` / `lockfileToAuditTree` removed. `lockfileToAuditIndex.ts` exports `lockfileToAuditRequest` (flat POST body + counts) and `buildAuditPathIndex` (only invoked when the response has advisories).
- `AuditAdvisory.findings` is now `AuditFinding[]` (was an unintended 1-tuple).
- Top-level test fixtures regenerated from real `registry.npmjs.org` responses; synthetic `update-*` fixtures converted in place to bulk shape.
---------
Co-authored-by: John van Leeuwen <john.van.leeuwen@priva.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>