Commit Graph
13302 Commits
Author SHA1 Message Date
Chris WoodandZoltan Kochan 734cd5bf65 feat(config): allow nodeDownloadMirrors in the global config and env (#13610)
node-download-mirrors was never registered in pnpmTypes. Both the pnpm_config_* environment pass and the global config file allowlist work off that schema, so the setting was invisible to both.

nodeDownloadMirrors could only be read from a pnpm-workspace.yaml, which is per-project config, so a Node.js download mirror could not easily be configured once for a machine.

This commit registers the key, updates the env parser to accept a JSON object, and adds it to the global config file allowlist.

The Rust stack already read this setting from both the environment and the global config file. To keep parity, this commit gives its config_types.rs mirror of
pnpmConfigFileKeys the same entry, so the Rust `pnpm config set -g` honours the key.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 21:51:05 +02:00
Trevor BurnhamandZoltan Kochan bc7dc8bd6e fix(config): hand updateConfig the resolved config, not one file's settings (#14680)
The `updateConfig` pnpmfile hook was passed the parsed contents of
`pnpm-workspace.yaml`, so a hook could not read a setting that came from
`.npmrc`, the command line, or a default. Scoped registries declared in
`.npmrc` were the case that surfaced it: `registriesByScope` was absent
from the input entirely, and `registries` was null, so a hook that routes
packages by scope could not work on pnpm 12. pnpm 11 passes the hook its
resolved `Config`.

`WorkspaceSettings::from_resolved` is the inverse of `apply_to`: it
reports every setting at the value the config resolved it to. Two groups
report as the user set them instead, and read as unset when nothing did:

- The path settings `apply_to` anchors against a base directory.
  Anchoring happens on apply, so reporting the anchored path would
  re-anchor a relative setting against wherever the hook's answer lands.
  pnpm reports these as written too.
- The settings pnpm reads for *whether* they were set, `preferFrozenLockfile`
  among them. Reporting the resolved value would leave a hook assigning
  that same value with no change for the delta to notice, and the setting
  would go on counting as unset -- which would have made a CI install
  keep the frozen-lockfile default the hook meant to override.

The identically-named settings `apply_to` and `from_resolved` share now
live in one macro, so teaching either direction about a setting teaches
the other. `from_resolved_reports_every_setting` fails if a new setting
reaches `WorkspaceSettings` without a mapping.

`resolved_config_views` adds the resolved state that is not a settings
key, under the names pnpm 11 uses: the registry routing maps, `authConfig`
and `configByUri`, and the resolved directories. `configByUri` carries
every credential scope of a registry, from a new
`Config::registry_creds_by_uri`, and `registriesByPrefix` is omitted when
the project declares no prefix, as pnpm 11 omits it. A pnpmfile runs as
unrestricted Node in both versions, so the credentials among these are
nothing a hook could not already read from `.npmrc` itself.

Every setting a hook changes is recorded in `explicit_settings`, as loading
a settings file records it, and a setting set to null is dropped from
there, so the derivations that read whether a setting was set at all see
the hook's answer. A setting set to null returns to its default through
`WorkspaceSettings::reset_setting_to_default`, the deletion counterpart of
`apply_to`, which re-runs the derivations that read the setting against
the settings still set. `stateDir` is applied against the host's state
root.

A hook may also rewrite `registriesByScope` and `registriesByPrefix`,
since it reads routing under those names. This matches pnpm 11, including
its precedence: an explicit hook assignment outranks `--registry`. See
pnpm/pnpm#14063 for the open question of whether that precedence is right
in either version.

A setting nothing set is absent from the configuration a hook receives
rather than present with the value `null`, so `'key' in config` answers the
same in both versions and a hook cannot mistake a null for a configured
value. This also retires the `registries: null` the issue was reported
against: `registries` is a shape only `pnpm-workspace.yaml` has.

These keys report what pnpm 11 reports: `registriesByScope.default` and
`authConfig.registry`, the default registry every unscoped package is
fetched from, which the config holds beside the scope map rather than in
it; `pnpmfile`, the pnpmfiles being run; `cacheDir`, falling back to the
directory pnpm chose for the host; and `sideEffectsCache`, in the boolean
shorthand when reads and writes agree.

Closes pnpm/pnpm#14676

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 21:45:52 +02:00
Benjamin StaneckandZoltan Kochan 51fc229663 perf(lockfile): normalize hash input in place (#14707)
Sort the owned JSON tree with serde_json before streaming it into SHA-256.
Remove the recursive helper that cloned keys and nested values at every
object level. Preserve pruning, array order, and exact existing digests.

Add fixed-digest compatibility coverage and an array-order test. Existing
cache tests continue to cover reuse across lockfile paths.

Gate the existing platform-specific Cargo test imports to match their test
and keep the macOS workspace lint check passing.

Closes https://github.com/pnpm/pnpm/issues/14706.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 21:44:39 +02:00
4ca0c170b5 test(testing-utils): detach git fixtures from the contributor's global config (#14715)
* test(testing-utils): detach git fixtures from the contributor's global config

`pipeline_cache::symlinked_inputs_are_hashed_as_link_targets` builds a
project whose tracked input is a symlink named `CLAUDE.md`, and asserts
that retargeting the link invalidates the task. On a machine whose
`core.excludesFile` ignores that name, `git add -A` never stages the link
and the `git ls-files --cached --others --exclude-standard` pnpm runs to
collect cache inputs never lists it. The link is then not part of the
task's cache key, retargeting it changes nothing, and pnpm correctly
reports the hit the test refuses.

The symlink hashing the test covers is right, so the fixture is what
needs fixing. `detach_from_global_config` points `core.excludesFile` at a
path that does not exist, in the repo's own local configuration. Local
configuration also reaches the `git` pnpm itself spawns inside the repo,
so one setting covers the fixture's staging and the listing under test.
The path is inside `.git/info` rather than `/dev/null` so it holds on
Windows too. The `gpgsign` overrides `GitRepoFixture::init` already made
for the same reason move in beside it.

`init_isolated_repo` gives the two suites that call `git init` directly
the same isolation without the work tree and bare clone pair
`GitRepoFixture` exists to provide.

`symlinked_input_project` now checks that the link is staged before any
caching assertion runs, because the failure this produced named neither
git nor the ignore rule that caused it.

Closes pnpm/pnpm#14713

* fix(cargo): gate the non-UTF-8 name test's imports with the test

`import_package`, `CheckoutPackage`, and `BTreeSet` are referenced only
by `a_file_whose_name_is_not_utf8_is_refused`, which is compiled out on
macOS because APFS answers `EILSEQ` for the name it needs. At module
scope they were dead imports there, so `cargo clippy -D warnings` failed
on macOS while passing on Linux.

* test(testing-utils): narrow the fixture isolation contract to what it delivers

`init_isolated_repo` promised that a contributor's global git
configuration could not reach the repo, which overstated three
`git config` overrides. Full isolation is not available here: the point
of writing the overrides into the repo's local configuration is that they
also govern the `git` pnpm spawns inside it, which an environment
variable on the fixture's own commands would miss. So the doc now names
the settings it handles and says that anything else still reaches the
repo, and `detach_from_global_config` becomes `override_global_config` to
match.

`core.hooksPath` joins the overridden set. A contributor who points it at
a hooks directory globally would otherwise have their own hooks, which
are arbitrary code, run on a fixture's commits.

`init_isolated_repo` passes `-b main` rather than leaving the branch name
to `init.defaultBranch`, as `GitRepoFixture::init` already does.

`tracked_files` replaces the hand-rolled `git ls-files` in
`symlinked_input_project`, which read stdout and ignored the exit status.
A failing `git` would have surfaced as an empty listing, blaming the
ignore rule the assertion exists to detect for what was a git error. The
existing `git` helper already asserts the status and carries stderr into
the panic.

* Fix typo in documentation comment for git_repo.rs

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(testing-utils): isolate the bare repo and every suite that commits

Running the git-backed suites with `GIT_CONFIG_GLOBAL` pointed at a
hostile configuration (a global excludes file, a `core.hooksPath` whose
hooks all fail, `init.defaultBranch = trunk`, `gpgsign = true`) showed
three gaps the fixture isolation still left open.

`GitRepoFixture` overrode the work tree only. `git push` into the bare
repo runs `pre-receive` and `update` from that repo's own
`core.hooksPath`, so a contributor's global hooks rejected every mirror.
The bare repo now gets the same overrides, and `override_global_config`
takes the git directory so the placeholder path lands inside it for
both repo shapes. The bare init also passes `-b main`: without it,
`HEAD` inherits `init.defaultBranch` and points at a branch the mirror
never pushes.

`pipeline_cargo_cache` and `exec_recursive` also `git init` their own
repos and commit into them, and both failed on the global `pre-commit`
hook. They now go through `init_isolated_repo`, which makes their
per-command `-c user.name`, `-c commit.gpgsign=false`, and
`--no-gpg-sign` overrides redundant.

`tracked_files` listed tracked and untracked-but-not-ignored files, so
it is now `unignored_files`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

* test(testing-utils): keep global templates and attributes out of fixtures

Two more user-global settings reached the fixture repos.

`init.templateDir` seeds every new repo's `info/exclude`, and no
configuration setting overrides that file, so a template that ignores a
fixture name hid it from `git add -A` and from the listing pnpm hashes
exactly as the global excludes file did. Every fixture `git init` now
passes `--template=`, which skips the template directory entirely.

`core.attributesFile` can assign a `clean` filter to a fixture's files,
and git runs that filter, the contributor's own command, on `git add`.
The override now points it at the same absent path as the excludes file
and hooks directory.

Verified under a global config carrying a template whose `info/exclude`
lists `CLAUDE.md` and a required `*.md` clean filter that exits 1: the
seven git-backed suites and the `cargo_deps::git` unit tests pass, and
a repo initialised the old way loses the file and fails the add.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

* test(testing-utils): keep a global fsmonitor out of fixtures

A user-global `core.fsmonitor` names a command git consults whenever it
refreshes the index, so `git add` and commit in a fixture repo ran the
contributor's own program. The fixture override now sets it to `false`
locally, beside the hooks and attributes overrides that close the same
class of setting.

Verified with a global `core.fsmonitor` that prints and exits 1: a repo
initialised the old way runs it on `git add`, the fixture does not, and
the seven git-backed suites and `cargo_deps::git` unit tests pass under
the full hostile configuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 18:44:13 +00:00
Zoltan Kochan 21e6009756 refactor(deps-restorer): run the frozen install as a pipeline of phases (#14739)
Restructure InstallFrozenLockfile::run as a pipeline of &self phases —
plan_materialization, settle_skip_set, fetch, link, build — each
returning one struct the next consumes whole, where run alone bound 77
names.

The plan borrows the allow-builds policy rather than owning it: the
directory-clone cache keeps a reference to the policy, so the two cannot
share a struct. Every phase returns an explicit Send future capturing
only its Copy inputs, because a future holding &self would not be Send
(the verification override is a boxed future without Sync). The owned
inputs run consumes are taken off self before the phases borrow it.

Hand each phase the install's borrowed inputs as one Copy value,
FrozenInputs, and keep each phase's output whole for the next; run and
every phase bind at most twelve names.

Drop the DependencyGroupList type parameter: the one caller hands a Vec
and run collected the iterator into another Vec on entry.

Related to pnpm/pnpm#14562.
2026-09-09 20:53:24 +02:00
Zoltan Kochan a60dc904bc fix(executor): wait for an interrupted script before exiting (#14735)
Ctrl+C raises SIGINT in every process of the terminal's foreground group,
so it reaches pnpm and the script pnpm started at the same moment. pnpm
had no handler for it, so it died first and the shell drew its prompt
while the script was still shutting down. Anything the script wrote after
that landed on the prompt, including a terminal's answer to a query the
script had left open, which is what pnpm/pnpm#14723 reported as escape
sequences after Ctrl+C. pnpm v11 does not have this bug: its lifecycle
runner registers SIGINT and SIGTERM handlers for the length of a script.

Every child spawned through `spawn_child` now joins a relay in the new
`pnpm-executor` `interrupt` module for the length of its run. A handler
for SIGINT, SIGTERM and SIGHUP passes the signal on to those children and
lets pnpm keep waiting; with no child left to wait for it restores the
default disposition and re-raises, so an idle pnpm still dies at once. A
child in a process group of its own is addressed by its group, since the
terminal's own signals never reach it. The relay escalates the way v11's
does, so a script cannot trap the terminal: a child's second interrupt is
relayed as SIGTERM, and once every child pnpm reaches has sat through
both, the next one takes pnpm down. The count lives on the registration
rather than on the process, so a script that starts later still gets a
plain first interrupt, and a script that ignores them cannot have its
escalation reset by a sibling starting beside it.

A spawn takes an entry before it clears the count on it, so a thread that
loses the race for a free entry cannot wipe the escalation of whichever
child won it. A relay re-reads a registration before signalling it. pnpm reaps a child
before releasing its entry, so an entry that turned over since the walk
read it names a process pnpm no longer owns, and the child that took the
entry keeps its own first interrupt.

Registrations live in an append-only list whose entries are leaked and
reused rather than freed. The handler therefore only reads atomics, which
is async-signal-safe, and the list has no fixed size: a bounded table
would stop covering children once a wide `--parallel` run filled it, and
pnpm would then wait for children that never received the signal. A
signal pnpm was started with ignored keeps that disposition, so `nohup`
and background jobs are unaffected.

Ending pnpm from inside the handler unblocks the signal before re-raising
it. A handler runs with its own signal blocked, so `raise` would leave it
pending until the handler returned and the `_exit` fallback would report
a plain exit code where the caller expects death by a signal.

pnpm now also ends the way the script did: a script killed by a signal
makes pnpm re-raise that signal on itself rather than exit 1, so the
shell reports an interrupted command. That is v11's behavior too, and
without it surviving SIGINT would have turned an interrupted `pnpm run`
into a plain failure. `run`, `exec` and `dlx` share that ending. An
install's lifecycle scripts keep reporting a failed script instead, so
that a signal reaching an optional dependency's script stays optional.

The tests spawn pnpm with the interrupt signals unblocked and back at
their default disposition, since both are inherited through `exec` and a
harness that ignores or blocks SIGINT would leave the interrupt inert.
Their scripts are `exec`ed by the shell so the process pnpm registers is
the script itself: the relay addresses its direct child, and whether a
plain `sh -c` command leaves a shell in between differs between shells.

Closes pnpm/pnpm#14723
2026-09-09 20:15:24 +02:00
548ef6543a fix(tarball): install a tarball entry that sits at the archive root (#14705)
* fix(cargo): gate the non-UTF-8 name test's imports with the test

`import_package`, `CheckoutPackage`, and `BTreeSet` are referenced only
by `a_file_whose_name_is_not_utf8_is_refused`, which is compiled out on
macOS because APFS answers `EILSEQ` for the name it needs. At module
scope they were dead imports there, so `cargo clippy -D warnings` failed
on macOS while passing on Linux.

* fix(tarball): install a tarball entry that sits at the archive root

`clean_archive_entry_path` dropped the top-level component from every
entry path and rejected the entry when nothing remained. An entry at the
archive root has no top-level directory on it to drop: `README` beside
`package/`, or the zero-length `._*` AppleDouble that macOS `bsdtar`
emits when the source cannot store xattrs natively. One such entry
failed the whole archive with ERR_PNPM_TARBALL_IO_ERROR, after
exhausting the network retries even for a purely local `file:` read.

An entry of a single segment now keeps that segment, which is what pnpm
11 does: `parseString` in `parseTarball.ts` advances past the first
separator, and a single segment has none, so pnpm keys the file by its
own name. Matching it keeps the `index.db` both implementations share
describing one file layout -- the concern pnpm/pnpm#14165 raised for the
sibling `./`-prefixed case, which pnpm/pnpm#14247 settled the same way.
npm instead skips the entry, since node-tar's `strip` leaves its path
empty, which resolves to the extraction directory and is dropped for a
non-directory. Skipping reads better on its own terms, but it would
diverge from pnpm 11 over a file pnpm 11 keys into the shared store.

A lone `.` stays rejected. It names the archive root rather than a file
inside it, so no key can address it, and the message no longer blames a
missing payload: a zero-length root-level file always had one.

Both extraction loops are covered, since a download routes between them
by size and the shared `index.db` must not be able to tell them apart.

Closes pnpm/pnpm#14701

* fix(tarball): read a manifest at the archive root at resolve time too

`is_root_manifest_entry_path` dropped one path component unconditionally,
so it did not recognize a `package.json` that sits at the archive root
with no wrapping directory around it. Extraction keys that entry as the
package's manifest, and the two now disagreed: a `file:` archive packed
without the `package/` wrapper resolved with no manifest at all, so it
was recorded under the alias its consumer gave it at version `0.0.0`
while the `package.json` extracted beside it named something else.

pnpm 11 records the bundled name and version for the same archive, so
the two CLIs wrote different lockfile keys for it and a mixed-CLI
`--frozen-lockfile` install failed. Before the parent commit the archive
was rejected outright, so the divergence arrived with it.

The rule now has one home: `is_root_manifest_entry_path` asks
`clean_archive_entry_path` whether the entry cleans to `package.json`,
which is the same question extraction answers, so the resolve-time read
and the extraction that follows it cannot drift apart again.

An archive carrying both `package.json` and `package/package.json` keeps
the last entry, as it did, which is what pnpm 11 does with the two keys
it also collapses into one.

`tarball_entries` generalizes the single-entry fixture builder the
`pnpm-testing-utils` fixtures already shared, so an archive with entries
beside the payload directory needs no builder of its own.

* docs(changeset): note the flat-tarball identity fix

The release note covered only the extraction failure, not the `file:`
archive that installed under its consumer's alias.

* docs(tarball): state the manifest-agreement contract, not its history

The doc comments on `is_root_manifest_entry_path` and the tests around it
narrated what the code did before this branch. Describe what the code
guarantees instead: the resolve-time read and the extraction must name
the same entry, and what a disagreement costs. Raised in review against
the comment rules in AGENTS.md.

* docs(changeset): drop the parity clause from the release note

A changeset states what pnpm does now, so the note that pnpm 11 puts the
file in the same place belongs in the PR description. The flat-tarball
paragraph now gives the old behavior its own past-tense sentence instead
of trailing it off the new one. Both raised in review against the
changeset style rules in AGENTS.md.

* test(testing-utils): write fixture tar entries as explicit regular files

`tarball_entries` documents that it writes each entry as a regular file,
but left the typeflag at the NUL its zeroed GNU header starts with. That
reads as a regular file, so nothing was broken, but the header did not
say what the doc comment claimed and the tar writers in
`pnpm-tarball`'s own tests all set it.

`tarball_entry` became a call through to `tarball_entries` with nothing
of its own to add, so its two callers now build their one-entry list
directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDSoY95Giu3QRwMsqZ4ys8

* refactor(tarball): reject the archive root before stripping a component

Rejecting a lone `.` is a property of the raw entry path, not of what
survives the top-level strip, so `clean_archive_entry_path` now asks
that question first. The strip cannot produce a lone `.` anyway --
`archive_entry_segments` keeps `.` only at index 0 -- so the order is a
statement of intent rather than a behavior change, and the check reads
as a plain slice comparison instead of a let chain.

The single-segment rule also holds for a flat archive that has no
wrapping directory at all, not just for an entry beside `package/`, so
the doc comment names both shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDSoY95Giu3QRwMsqZ4ys8

* test(tarball): pin that a nested manifest is not the package's own

`./package/package.json` keys as `package/package.json`, so it is a
manifest in a subdirectory and the resolve-time read has to say so.
Naming the package after it would name it after a file extraction
places one level below the package root. Extraction's side of that
agreement was already pinned; the resolve-time side was not.

The two archive builders the root-entry tests added differed only in
their entry lists, so they share one `tar_with_entries` helper, and
`tar_with_raw_entry_name` takes the body it writes instead of a fixed
five bytes -- a raw header name is the only way to keep a leading `./`,
which `set_path` drops from a multi-component path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDSoY95Giu3QRwMsqZ4ys8

* perf(tarball): clean an archive entry path without allocating per segment

`archive_entry_segments` copied the whole path to fold `\` to `/`, then
allocated an owned `String` per segment. Splitting on both separators at
once is the same partition, so the segments can borrow from the caller's
path and only the joined result allocates.

`clean_archive_entry_path` goes from 73ns to 34ns per call on a
four-segment path. It runs for every regular file of every extracted
archive, and it is what the resolve-time manifest read now asks whether
an entry is the package's own `package.json` -- the metadata scan of a
20k-entry local tarball drops from 5.0ms to 4.1ms against 3.2ms before
that read delegated here.

The absolute-path guard has to name `\` explicitly now that no fold
precedes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDSoY95Giu3QRwMsqZ4ys8

* test(tarball): cover an absolute archive entry path

An absolute entry names a destination of its own, so joining it onto the
package directory would write wherever the archive says. The guard
existed but nothing exercised it, in either separator's spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDSoY95Giu3QRwMsqZ4ys8

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:36:52 +00:00
Edward LongandZoltan Kochan d3ab8277a2 fix(global): fail closed on incomplete bin ownership (#13985)
Global package cleanup previously interpreted a declared dependency manifest
that could not be read as a package with no commands. The resulting partial bin
set could orphan a replaced group's command or remove a command still owned by
a surviving group.

Make dependency-manifest ownership enumeration fallible in both the TypeScript
and Rust implementations. Snapshot target and survivor ownership before global
activation or deletion, make multi-target remove preflight the whole command,
and preserve both the ownership and fresh-install cleanup diagnostics when both
operations fail.

Add real-filesystem and CLI regressions for incomplete enumeration, repair and
retry, protected shared commands, and mutation-free failure paths.

Fixes pnpm/pnpm#13796.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 17:44:19 +02:00
Ayush SinghandZoltan Kochan 7a2390a0d1 fix(deps-resolver): copy a resolved manifest before writing to it (#14014)
The npm resolver returns the manifest object its metadata cache holds, so every
dependency that resolves to the same package version gets the same object.
Resolving a dependency then writes to it: `dependencies` is defaulted, the
read-package hook rewrites dependency fields, a deprecation notice is carried
over from the lockfile, and an `engines.runtime` entry becomes a dependency.
Copy the dependency and peer fields first, down to each peerDependenciesMeta
entry, so one install cannot decide what the next one resolves.

The copy is deliberately shallow and limited to the fields those writes reach.
Deep-cloning the manifest in the resolver instead costs ~37us per manifest
against ~0.1us here, on a path that runs once per resolved dependency edge.

Related to pnpm/pnpm#13988. That issue's exact symptom could not be
reproduced, so this is not claimed as its fix.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 16:52:27 +02:00
Zoltan Kochan fc56dc12ec refactor(rust): give the install phases a shared context and a typed lockfile view (#14737)
Pin dylint.toml to the perfectionist revision that ships
too_many_local_bindings, configure it at max_bindings = 12 with test
code exempt, and keep it disabled until the remaining production sites
are refactored.

Give one install's phases a shared InstallContext: the config, install
root, requester prefix, slot layout, node linker, allow-builds policy,
bin-link options and import-method counter were declared again on
every phase struct and threaded again by every caller. A phase borrows
the context instead. The skip set stays a parameter of its own because
phases mutate it between one another.

Borrow a lockfile's packages and snapshots as one LockfileEntries value
everywhere they were passed as two, and derive the link phase's and the
frozen install's maps from the lockfile they already hold, so a caller
cannot pair one lockfile's entries with another's maps.
LockfileEntries::of_previous_install owns the --force rule that was an
inline conditional at the frozen path's call site.

Build InstallPackageBySnapshot once per install and pass each snapshot
to run, which is what makes run_cold_batch extractable. Name the phases
that were inline blocks: run_cold_batch, fetch_verified (which owns the
rule that a fetch error must not surface before the verification
verdict), run_on_disk_phases, requires_build_by_key. Name the
(layout, hoisted-paths) pair as PkgRoots.

Related to pnpm/pnpm#14562.
2026-09-09 16:50:44 +02:00
Zoltan Kochan def162fbc3 fix(cli): honor dedupe workspace selection (#14736)
Route dedupe through the existing install-family workspace selection and
dedicated-project scheduler. Dedupe previously initialized only the active
project and bypassed selection, leaving separate workspace lockfiles stale.

Pass shared-workspace selection into the install and lockfile-check paths.
Keep per-project lockfile snapshots and restoration for dedupe --check,
including changes to the root lockfile when filters exclude the root.

Add CLI regression coverage for default and explicit recursion, filters,
workspace-root inclusion, no-match exit codes, shared and dedicated
lockfiles, and check-mode restoration. All three new regression tests fail
against the original implementation. A fourth regression test verifies
that configuration-dependency changes fail a check even when filters
exclude the root.

Closes pnpm/pnpm#14732.
2026-09-09 16:25:51 +02:00
Zoltan KochanandClaude Opus 5 38d0051640 fix(package-manager): apply ignoredOptionalDependencies when resolving (#14738)
pnpm 12 threaded `ignoredOptionalDependencies` into the lockfile
freshness check and into the fast lockfile rewrite that absorbs a
widened pattern list, but never into the read-package hook chain a
fresh resolve runs. The setting was therefore honoured only when an
existing lockfile could be rewritten in place; any resolution from
scratch pulled the ignored packages back in and dropped the setting
from the lockfile. An up-to-date lockfile hid the regression, so it
surfaced later on `pnpm dedupe`, `pnpm add`, and the first install
that had to resolve.

Add the removal as the last hook in the chain, matching
`createReadPackageHook`'s order, and apply the same removal to the
importer manifests the resolve reads. Running last is what lets it
also drop the optional dependencies that `packageExtensions` and the
pnpmfile's `readPackage` introduce, rather than only the ones a
package publishes.

Closes pnpm/pnpm#14729


Claude-Session: https://claude.ai/code/session_01JtYF6AmaoERa1BUW5xBZq1

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:25:05 +02:00
Zoltan Kochan c75a0a2bd9 test(install): cover overrides above workspace root (#14734)
Extend frozen-install regression coverage for external local overrides at two
importer depths. The runtime fix already landed in pnpm/pnpm#14572. Restoring
the old freshness-check anchoring makes the new test fail with
ERR_PNPM_OUTDATED_LOCKFILE.

Closes pnpm/pnpm#14710.
2026-09-09 15:06:58 +02:00
Zoltan Kochan 8cd45fdf36 fix(binary-fetcher): unpack zip archives into a random directory (#14733)
AdmZip's Utils.writeFileTo opens each destination with fs.openSync(path,
"w"), which resolves symlinks, and Utils.sanitize only compares the entry
name as a string against the extraction root (GHSA-vwc7-r8mq-g2x9). A
symlink that already exists at a destination therefore redirects the write
anywhere the process can write. No patched adm-zip release exists.

extractZipToTarget extracted prefixed archives into path.dirname(targetDir),
which is the store's shared tmp directory, so every entry landed at the
fixed path <store>/tmp/<prefix>/... Node.js on Windows and Bun on every
platform set a prefix; Deno does not and already extracted into the random
directory from cafs.tempDir(). On a store shared by several users, another
user could pre-create a symlink at that fixed path and have pnpm overwrite
an arbitrary file. pnpm's own validatePathSecurity does not help: it checks
the entry string, which is exactly the check the advisory calls
insufficient.

Extract into a directory created with mkdtemp next to targetDir instead,
on the same filesystem so the rename stays cheap, and remove it afterwards.
That leaves no predictable destination to plant a link at, independent of
what adm-zip does.

The advisory is added to auditConfig.ignoreGhsas since no upgrade exists.
2026-09-09 15:02:57 +02:00
programming_baziandZoltan Kochan ecca53ebf2 fix(network): filter empty segments in no-proxy matcher (#14698)
## What

`NoProxyMatcher::from` reverses each `no-proxy` entry's dot-segments to build a reverse-dot-segment-prefix lookup table, but does so without filtering out empty segments first. This PR adds that filter on both the entry and host side.

## Why

A leading-dot entry such as `.npmjs.org` (the conventional "match this host and every subdomain" form used by curl, npm, and many corporate `NO_PROXY` values) splits to `["", "npmjs", "org"]`. Reversed without filtering, that's `["org", "npmjs", ""]`. `matches_host`'s position-by-position comparison against the host's reversed segments then hits the trailing `""` and fails at every length, so a leading-dot entry can never match any host, at any subdomain depth. Entries without a leading dot are unaffected.

Closes #14686, which has the full repro (a local proxy plus `NO_PROXY=.npmjs.org` vs `NO_PROXY=npmjs.org`) and already points at the same lines.

I ran into this independently while debugging a real corporate-proxy `NO_PROXY` hang: `pnpm install --frozen-lockfile` on pnpm 12.3.4 timed out fetching a tarball from a registry host explicitly covered by a leading-dot `NO_PROXY` entry, while `curl` against the same host succeeded instantly. Confirmed the same list without the leading dot fixed it, then traced it to this code.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-09 15:00:32 +02:00
Zoltan Kochanandgithub-actions[bot] c39ee2bff9 chore: update dependencies, Node.js, pnpm, and GitHub Actions (#14724)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-09 13:28:18 +02:00
徐天成 a9bed439d3 docs(security): list pnpm v12 as supported (#14674) 2026-09-09 13:17:35 +02:00
Dasa Paddock c05be611ec docs: update command reference from pnpm changeset to pnpm change in contribution guidelines (#14711) 2026-09-09 13:16:37 +02:00
Zoltan Kochan 2fa282950f style: adopt perfectionist's new size and shape rules (#14730)
Pin the perfectionist dylint library to a revision instead of a tag and
enable the four rules that revision adds: arbitrary_source_item_ordering,
core_instead_of_std, excessive_cognitive_complexity with a limit of 10,
and redundant_derive_more_forward_template.

The tree is made to pass them by refactoring rather than by exemptions:
no #[expect], no #[allow], and no raised limit. Oversized functions are
split into named steps, conditions that carried a meaning are given
names, three-way outcomes become enums, repeated parameter lists become
types that own the shared values, and the nearly sixty CLI config
overrides that repeated the same three shapes are driven from macro
tables.

cargo-dylint refuses a library built against a newer dylint_linting, so
CI moves to cargo-dylint/dylint-link 6.0.4 to match the pinned revision.
The 6.0.1 bootstrap failure the previous 6.0.0 pin worked around is fixed
there.

Related to https://github.com/pnpm/pnpm/issues/14562. That issue plans
seven size-and-shape rules; only excessive_cognitive_complexity has
landed upstream so far. It also proposed a per-site #[expect] for every
existing violation, which this PR deliberately does not do.
2026-09-09 13:15:59 +02:00
Zoltan Kochan 2f3ed22890 perf(cargo): speed up dependency installation (#14699)
Reuse package manifest paths returned by Cargo metadata during workspace
root discovery. A 65-package workspace needs one metadata invocation rather
than 65. Preserve discovery of independent nested workspaces.

Canonicalize discovered manifest paths and Cargo metadata paths before
membership filtering so relative paths and lexical aliases reuse metadata.

Queue directory paths and open each from the pinned workspace root. Native
whole-path opens reject every symlink/reparse point: openat2 with
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS on Linux, O_NOFOLLOW_ANY on macOS 11+,
and NtCreateFile with OBJ_DONT_REPARSE on Windows. Keep handles bounded and
issue one navigation open per directory without ascending or comparing file
identities. Narrow unsafe wrappers use existing libc/windows-sys dependencies.

Older Unix kernels and other Unix platforms use a component-by-component
no-follow fallback with bounded handles but depth-dependent open counts.
Detect older macOS kernels before using the flag, because they may ignore
unknown open flags. Skip transiently removed/unreadable nested directories.

Normalize expected test paths for macOS temporary-directory aliases and Windows
short paths. Verify the limited-descriptor child test actually runs one test.

Cache generated Cargo checksum files in the existing store index, keyed by
the archive checksum and the verified source-file mapping. Validate the
cached checksum CAS file before reuse. Regenerate missing or corrupted
entries and invalidate the cache when source mappings or archive checksums
change. Keep source integrity checks and materialized-slot repair intact.

On the repository's Cargo-only dependency graph, 35 interleaved warm runs
measure 90.5 ms for pnpm install versus 176.0 ms for cargo fetch. Synthetic
warm installs fall from 138.3 ms to 29.1 ms with the same release settings.
2026-09-09 01:25:28 +02:00
Zoltan Kochan c6cf52abe5 feat(pnpr): scope registry identity by ecosystem and expose directory (#14700)
Scope registry addressing by ecosystem and local name. Accept ecosystem
configuration groups, qualify their serving-table keys and router sources,
and support ecosystem-specific defaults while preserving flat configurations.
Give grouped hosted registries isolated default storage namespaces; retain
explicit org overrides for existing storage.

Use ecosystem-aware addressing for reads, writes, discovery, and defaults.
Keep upstream credential classification tied to the qualified identity and
reject encoded path separators in named HTTP endpoints.

Expose a caller-filtered registry directory with one entry per ecosystem and
name, ecosystem defaults, mount information, namespace patterns, and source
order. Withhold inaccessible registries and restricted routing metadata,
exclude secrets and upstream addresses, and disable response caching.

Test configuration ambiguity, source/default isolation, same-named registry
visibility, grouped Cargo publication and downloads, absence of cross-protocol
package reads, and resolver credential isolation. Document configuration,
migration, and directory semantics and include a pnpr minor changeset.
2026-09-08 23:14:15 +02:00
6b84457247 fix(package-manager): name the entry that could not be removed from node_modules (#14605)
`ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR` reports the OS error and nothing else, so
every entry under `node_modules` is a suspect. On 12.3.4, Windows 11:

```
Error: ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR

  × installing dependencies
  ╰─▶ Failed to remove modules directory contents: 拒绝访问。 (os error 5)
```

(`拒绝访问` is "Access is denied" on a zh-CN Windows.)

With this change, the same failure on the same machine:

```
  ╰─▶ Failed to remove
      C:\Users\me\AppData\Local\Temp\pxverify2\node_modules\is-odd from the
      modules directory: 拒绝访问。 (os error 5)
```

## Where it came from

I hit this upgrading an existing project from pnpm 11 to pnpm 12 on Windows. From a clean
directory, an install with `pnpm@11` followed by an install with `pnpm@12` fails on the
first v12 run and succeeds on the second, three times out of three:

```
cycle 1: v12 run 1 exit=1 [ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR]  run 2 exit=0
cycle 2: v12 run 1 exit=1 [ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR]  run 2 exit=0
cycle 3: v12 run 1 exit=1 [ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR]  run 2 exit=0
```

Narrowing down which entry was at fault took a bisect by hand, and I still got it wrong:
working from the outside I concluded the junctions pnpm 11 leaves *inside* `.pnpm` were
responsible. The patched binary answered it in one run, and the answer was the top-level
`node_modules/is-odd` junction instead.

**This PR does not fix that failure.** I have a reliable reproduction but not a confident
account of the mechanism, so I am not guessing at a fix. What I can fix is the part that
made the reproduction far harder to narrow down than it needed to be. If the underlying
upgrade failure is of interest, I am happy to open a separate issue with the full
reproduction.

It is a different failure from the transient Windows file-lock family in #14349, #14407
and #14549: those depend on another process holding a handle and clear on a retry, while
this one reproduces from a clean state every time and is specific to a pnpm 11 tree.

## The change

`RemoveModulesDir` becomes a struct variant carrying the path, the way
`PruneDirectDepsError::RemoveBin { path, error }` and
`InstallError::UnsafeFilteredModulesDir { modules_dir, workspace_root }` already do. It
names the entry the `remove_dir_all` and `remove_file` failures were on. The two
`read_dir` failures have the modules directory itself, so they build a sibling
`ReadModulesDir { path, error }` variant that says the directory could not be read; it
carries the same `ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR` code, so no new error code
reaches users. Neither variant has other consumers in the workspace.

It renders through `dunce::simplified` and `Path::display` rather than `{:?}`. The entries
come from a canonicalized modules directory, so `{:?}` prints the `\\?\` verbatim prefix
that #13990 removed from user-visible paths, and doubles every separator:

```
Failed to remove "\\\\?\\C:\\Users\\me\\...\\node_modules\\is-odd" from the modules directory
```

A path a user is meant to act on should be copy-pasteable. `dunce` is already a dependency
of this crate, and `path.display()` in an error is the shape used in `auth-commands` and
`cli`.

## Same shape, not included

Two variants in `InstallError` still wrap a bare `io::Error`:

- `CleanGitBranchLockfiles`, where a path would help for the same reason
- `ProjectLifecycleThreadPool`, where there is no path to name

I left them alone because I have not hit either. Happy to include
`CleanGitBranchLockfiles` here if you would rather it move in one go.

---------

Co-authored-by: xmhua <236586769+xmhuangzhijun-hue@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 19:08:22 +02:00
btea 4dde159286 chore: enable trustPolicyExcludePrune (#14695)
Explain what changed and why. Reference issues here too (Closes pnpm/pnpm#123).
2026-09-08 19:06:48 +02:00
Zoltan Kochan fb8625eda3 feat(pnpr): browse hosted packages and fix concurrent registration (#14696)
Add browse=true to the npm and Cargo search APIs. Reuse hosted discovery,
routing, authorization, result projection, and pagination. Skip upstream
search in browse mode. Preserve ordinary empty-search behavior.

Cover pagination and totals, named registry mounts, registry and package
access restrictions, routed-away packages, and zero upstream requests.
Document the endpoints and add a pnpr minor release note.

Serialize capped libsql registration transactions on the shared connection.
Use immediate transactions and bounded retries for SQLite lock conflicts
across independent backends. Retain the atomic counter as the user-limit
authority. Cache the bcrypt hash within each registration request.
Test independent databases opening the same file, lock release, extended
SQLite error codes, and retry behavior through the real libsql HTTP client.

Close temporary-file handles before atomic replacement and retry transient
Windows file locks. This fixes concurrent task-cache publication failures.
Add registration and Windows cache-write patch release notes.
2026-09-08 18:46:04 +02:00
Zoltan KochanandClaude Opus 5 feca83e973 fix(pipeline): hash symlinked inputs as link targets (#14693)
Task cache keys covered a project's tracked files by hashing each one
through `check_ancestors`, which rejects any symlink on the path. A
repository that tracks a symlink — pnpm's own `CLAUDE.md` points at
`AGENTS.md` — therefore aborted `pnpm pipeline` before a single task ran.

A symlink now contributes its link target to the key, the way git
records it, tagged so it cannot collide with a regular file holding the
same bytes. Following the link would hash a file the project does not
own, and a dangling link has no content to hash at all. The file a link
points at needs no following anyway: when it is in the project it is a
tracked input of its own, so editing it invalidates the tasks that read
it through the link. Everything above the leaf keeps the old check, the
project root included, so a symlinked directory still cannot redirect a
hash outside the project. Restoring cached outputs is unchanged.

This reverses the leaf half of
https://github.com/pnpm/pnpm/pull/14635, whose two unit tests for a
rejected leaf symlink now assert the target hash instead. Its parent
directory checks stand.

`--no-cache` no longer computes keys at all. Nothing reads them on that
path, and hashing every tracked file of every project is the bulk of the
work the flag exists to skip. Its test puts a tracked input under a
symlinked directory, which hashing still refuses, so the run only
succeeds when no key was built.

Closes https://github.com/pnpm/pnpm/issues/14692


Claude-Session: https://claude.ai/code/session_0165dQaxuqnuC7PnTdJTUgDx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:20:55 +02:00
Zoltan Kochan e23257434e feat(cargo): install the git-sourced crates a Cargo.lock pins (#14694)
A Cargo workspace that points a crate at a git revision, through
`[patch.crates-io]` or a git dependency, failed to install: every
non-registry source in `Cargo.lock` was rejected before it was fetched.

pnpm now checks out the commit the lockfile pins and vendors the crate
into the store the way `cargo vendor` lays a git package out — the
package's own directory, a `.cargo-checksum.json` with a null `package`,
and a Cargo directory source that replaces the git source. `cargo` then
builds the pinned revision offline, and `--frozen-lockfile` leaves the
lockfile and its revision untouched. Vendored git packages get their own
`.pnpm/crates/git` directory: a git package and a registry crate can
share a name and version, and one directory source cannot hold both.

A vendored manifest resolves the `workspace = true` fields its repository
supplied and drops the `[workspace]` table, so the directory stands alone
the way `cargo vendor`'s does. The store slot is keyed by the commit, so
a second install links it without cloning again, offline included.

Resolving a workspace that declares `[patch]` or `[replace]` is refused
rather than silently resolved without it: `cargo metadata` does not
report either table, so the lockfile would name the replaced package.

Closes pnpm/pnpm#14691
2026-09-08 17:03:09 +02:00
Zoltan Kochanandgithub-actions[bot] 2a762b7d53 chore: update dependencies, Node.js, pnpm, and GitHub Actions (#14681)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-08 13:29:35 +02:00
Zoltan Kochan efc41ac884 chore(release): pacquet 12.4.0 (#14687) v12.4.0 2026-09-08 12:24:58 +02:00
Trevor BurnhamandZoltan Kochan bcf58f7dd3 fix(deploy): run the source workspace's pnpmfile, not the deployed copy (#14672)
The deploy install resolved its pnpmfile relative to its own root, which
the shared-lockfile path points at the deploy directory. `copy_project`
puts the deployed project's packlist there, a `.pnpmfile.mjs` among it,
so the install loaded that copy and then failed the frozen-lockfile gate
with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH against the `pnpmfileChecksum`
the generated lockfile deliberately omits. The source workspace's own
pnpmfile went unloaded, inverting pnpm 11 on both counts.

Resolve the hooks up front from the selected project's lockfile root, the
directory an install of that project reads its pnpmfile from, and hand
them to the install as `pnpmfile_hook_override`. Where the source
workspace has no pnpmfile, the deploy install runs with `ignorePnpmfile`
on instead: discovery never runs next to the deployed manifest, and an
install with nothing to hook keeps the resolver fast paths that a hook
handle would switch off. The shared path wraps the hooks in
`ChecksumFreeHooks`, matching
pnpm's `calculatePnpmfileChecksum: undefined`: the hooks' effects are
already part of the deployed snapshots.

Resolving before the target is prepared also means a `pnpmfile` setting
naming a file that is not there fails the deploy without having emptied
the target first.

The legacy path keeps recording a checksum in the lockfile it resolves
for itself, and picks up the same fix where `sharedWorkspaceLockfile:
false` had pointed it at the deploy directory too.

Closes pnpm/pnpm#14671

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 12:14:43 +02:00
zhsamaandZoltan Kochan 4448d6ede1 fix(cmd-shim): retry locked bin removal and replacement (#14573)
Retry transient Windows file-lock errors when removing a command entry,
replacing a Node.js binary link, or renaming a new shim over an existing
one. Reuse the existing pnpm-fs retry policy, and share the shim-level
NotFound handling while preserving final errors and replacement error
context.

The TypeScript CLI already has bounded removal retries. Rust retains its
existing timing and classifier, including its known delay for permanent
PermissionDenied errors. Persistent failures remain fatal.

Add consumer-level coverage for deletion, link target preservation,
idempotence, and cleanup/replacement error propagation. Native Windows
tests use deny-delete handles and release them after a real failed
attempt, covering all three locked paths. A default-off test feature
supplies observation without changing production builds.

Fixes pnpm/pnpm#14549.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 11:01:19 +02:00
bteaandZoltan Kochan 9597b0834e feat: add trustPolicyExcludePrune (#14068)
Added a new setting `trustPolicyExcludePrune` (default: `false`). When enabled, `pnpm add`, `pnpm update`, and `pnpm remove` prune the entries of `trustPolicyExclude` in `pnpm-workspace.yaml` that the freshly written lockfile no longer resolves: versions that are gone are dropped (an entry is removed once none of its versions remain), and entries for packages that are no longer in the lockfile are removed too. Name patterns (`@scope/*`) are always kept. The cleanup is skipped when the install's lockfile does not cover the whole workspace (`sharedWorkspaceLockfile: false`), since entries another project still needs would look stale.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 10:54:56 +02:00
Ayush SinghandZoltan Kochan 584b6c8388 fix(resolving): encode full registry path into metadata cache key (#14081)
Registry metadata mirrors were keyed on host[:port], so several registries
served from one host under different path prefixes shared one directory and
could answer with each other's versions, integrity hashes and tarball URLs.

Both stacks now key the mirror on
<scheme>%3A+<host>[+<port>][%2F<path>][%5F<sha256>]. The host and every path
segment are percent-escaped down to [A-Za-z0-9._-], so a `%` or `+` in a key
is always one the encoder wrote and the key can carry no path separator, no
character Windows rejects in a filename, and no glob metacharacter — the
cache commands feed the key to a glob whose matches `pnpm cache delete`
removes. The scheme is included because http and https at one host and path
are two different trust domains: metadata served over http can be rewritten
in transit and must never reach a resolution configured for https. The
separators are percent-escapes because every key pnpm wrote before this
change was a URL host, which can never hold a `%`; no new key can therefore
land on the stale directory of an unrelated one whose hostname held a
separator, which would otherwise let `https://nexus/npm/` read what was
cached for `https://nexus_npm/`. A path that is not all lowercase gets a
sha256 suffix, the guard encodePkgName already applies to package names, so
HFS+ and NTFS cannot merge two registries; a trailing `.` is escaped because
Win32 strips one; and a key too long for a 255-byte filename is replaced by
its own hash. Only the one trailing slash the resolver itself appends is
normalized away; a repeated slash reaches the registry as a distinct request
path and stays in the key.

Every cache directory is renamed, so the first install after upgrading
refetches registry metadata once. The package store is untouched.

`pnpm cache view` decodes the key back to the registry rather than replacing
`+` with `:`, the registry URL is redacted in both stacks' errors, and the
Rust diagnostic codes now match pnpm's.

Closes pnpm/pnpm#13558.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 09:31:51 +02:00
Zoltan Kochan ff88d94c66 fix(cli): accept an explicit value on a boolean flag (#14662)
nopt splices `--prod=false` into `--prod` `false` and reads the value back
as the flag's, so pnpm 11 takes an explicit boolean on every option typed
as `Boolean`. pacquet's clap flags take no value at all, so a build host
that appends `--prod=false` to its install command, as Render does, hits
"unexpected value 'false' for '--prod'" instead of an install that keeps
devDependencies.

A new pre-clap pass collapses the token over argv: a true value leaves the
bare flag, a false one becomes the `--no-` negation that
`boolean_negations` already pairs with every boolean flag. Teaching clap a
value form instead would hang a `[<VALUE>]` placeholder off every boolean
in the help output; rewriting argv leaves the grammar, and the help, as
they are written. Every command line the pass rewrites is one that aborts
the parse today. Aliases resolve to the flag they name, so
`--production=false` reaches `--no-prod`, and a name another command
spells as a value-taking option is left alone.

A value written as its own token (`--prod false`, which nopt reads the
same way) is deliberately left alone. Claiming it belongs in
`option_width`, the width rule every pre-clap scan shares, and a foreign
command line names its program with a positional: `pnpm -r exec
--report-summary true` runs `true`, which a flag claiming the token after
it would swallow.

Closes pnpm/pnpm#14553
2026-09-08 09:26:49 +02:00
2d517857b6 test(patching): parse the diffs that patch-commit writes (#14659)
The commit-side tests assert the text of the `diff --git` header, but nothing
checked that what `patch-commit` writes is what `apply` reads back. Parse the
generated diff through `PatchSet` with the options `apply` uses and assert the
file operation that comes out.

The two cases fail differently. `git diff --irreversible-delete` writes a
deleted file without a `---` marker, so a malformed header leaves the parser
nothing to recover from and the parse itself fails. An added file carries a
`+++` marker the parser can recover the target from, so its header is asserted
directly. The added file sits in a new directory, a path shape no other
commit-side test covers.

The path fix these cover landed in pnpm/pnpm#14621. Behavior is unchanged, so
there is no changeset.


Claude-Session: https://claude.ai/code/session_01KAUuXhppXK6XemZrTPSCW8

Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:26:11 +02:00
Zoltan Kochan 005c6f399f fix(config): apply packageConfigs to per-project installs (#14661)
`packageConfigs` had no field, parser, or apply path in pnpm v12. The key
was listed among the recognized settings, so the unknown-setting check
stayed quiet, and every entry was dropped. A workspace that pinned a
version through `packageConfigs.<project>.overrides` resolved that
project against the workspace-wide graph instead, with nothing in the
output to say so.

The setting is now read from `pnpm-workspace.yaml` in both of its
spellings, a map from project name to settings and a list of entries that
each name the projects they apply to, validated field by field, and
flattened to the `project name -> settings` lookup `Config` carries. Its
deserializer is written out rather than derived as an untagged enum so a
malformed entry still reports which setting is wrong.

`Config::anchor_dedicated_project` overlays a project's entry right after
it anchors that project's paths, so every command that installs one
project of a `sharedWorkspaceLockfile: false` workspace on its own -
install, add, update, remove, rebuild - reads `overrides`, `hoist`,
`modulesDir`, `saveExact` and `savePrefix` from it. The read of the
project manifest the lookup needs happens only when the setting is
present.

A workspace that shares one lockfile resolves and writes every project
together, so it runs no per-project install for an entry to reach. pnpm
v11 ignores `overrides` and `hoist` there too, and pacquet models neither
a per-importer `modulesDir` nor a per-importer range style to hang the
other three on. Rather than drop them silently, the install now names the
entries it ignored.

Closes pnpm/pnpm#14556
2026-09-08 09:25:46 +02:00
Zoltan Kochan 7abf8f6c90 test(pnpr): take the npm publish fixtures from one place (#14669)
Seven suites carried their own copy of the publish document a client sends
and the two digests it puts in `dist`. `tests/common/npm.rs` holds them since
the multi-replica suite was added; the rest now include it instead of keeping
a copy each.

The copies had already drifted: the S3 suite built its attachment filename
from the whole package name, so it would have addressed a scoped package's
tarball under the scope. Nothing depended on that, since its packages are
unscoped.
2026-09-08 08:20:23 +02:00
Zoltan Kochan 06db4f1ba4 feat(pnpr): keep pipeline run records with the hosted packages (#14668)
A run record is the account `pnpm pipeline` gave of one run: testimony about
something that happened once, not derived data a replica can rebuild. It lived
on the replica that received the submission, so behind a load balancer the
pipeline UI showed a different history depending on which replica answered,
and a scale-to-zero container took its records with it.

Run records now live in the hosted store, beside the packages every replica
shares. They keep their append-only rule across replicas: the create-only
write the object store and the local backend both offer decides the winner, so
a run id recorded on one replica is refused on every other.

The staged-publish namespace already needed read, create-only write,
compare-and-set replace, remove and list on a reserved namespace of the hosted
store. That is now the backend's record API, addressed by namespace and key,
with staged publishes and run records as its two callers rather than a second
copy of the plumbing. `Storage` names the namespaces, as it already did for
`.staged`.

On a local storage root the records move from `pipeline-runs/v0` to the
reserved `.pipeline-runs/v0`, which also takes them out of reach of a package
named `pipeline-runs`.

Related to pnpm/pnpm#12199.
2026-09-08 08:19:11 +02:00
Zoltan Kochan 8760cfa9f3 fix(pnpr): approve a staged publish once across replicas (#14665)
Staged publish records live in the hosted store, which every replica of an
S3-backed deployment shares, but an approval read the record and replayed the
held publish without ever writing the record back. Two approvals of one stage
reaching two replicas therefore both ran the publish, and a rejection that
landed between another replica's read and its publish did not stop it.

The approval now claims the record first: it rewrites it with the time the
approval started, conditional on the bytes it read. The object-store backend
puts that write under `If-Match` on the record's `ETag`; the local backend,
which one process owns, compares the bytes under a staged-write lock. A second
approval finds a record that is no longer the one it read and answers 409, or
404 once the winner has consumed the stage. The claim is released on every
failure path, and expires after ten minutes so a replica that died mid-approval
does not strand the stage.

Staged records are also written create-only now, so a record can never be
written over one another request owns.

Adds a multi-replica test suite that drives two routers over one object store
the way a load balancer spreads requests over two containers: concurrent
publishes of one package keep both versions, a stage created on one replica is
approved on another, two replicas approving one stage publish it once, and a
dist-tag written on one replica is served by the other.

Related to pnpm/pnpm#12199.
2026-09-07 20:46:29 +02:00
Zoltan Kochan 4ed7a436ef feat(pnpr): add oidc and keyless publishing (#14666)
Add operator-configured OIDC providers, explicit subject/claim bindings, and
separate browser and workload authentication paths to pnpr. Use the approved
openidconnect dependency for discovery, authorization code exchange, and
signature verification, with pnpr checks for authorized party and token times.

Keep OIDC sessions short-lived and out of the persistent token stores. Bind
browser login to a signed HttpOnly Secure cookie, PKCE, nonce, and one-use state.
Anonymous login starts allocate no server-side state. Bound callback attempts
and concurrent exchanges. Bound successful sessions,
discovery response sizes, deadlines, and JWKS refreshes. Restrict discovery
destinations to public IP addresses through the shared system DNS resolver.
Keep valid cached keys available while a refresh performs network I/O.
Redact callback query parameters from request logs.

Accept prefixed workload ID tokens as registry credentials without token-store
lookups, restricting them to exact packages in an explicitly named hosted npm registry and enforcing the
normal publish ACL. These credentials cannot mint durable pnpr tokens or use
account, unpublish, batch, or other protocol endpoints.

Document provider setup, limitations, and a GitHub Actions workflow that needs
no long-lived registry secret. Cover signed-token validation, claim constraints,
login replay and expiry, discovery caching, and request restrictions with tests.
2026-09-07 20:45:28 +02:00
Zoltan KochanandClaude Opus 5 e91c63887e feat: add android-arm64 and android-x64 builds (#14660)
Build the Rust CLI and the NAPI addon for aarch64-linux-android and
x86_64-linux-android and publish them as `@pnpm/exe.android-*` and
`@pnpm/napi.android-*`, so installing pnpm in Termux resolves a native binary
instead of failing the preinstall with no prebuilt binary for the platform.
Closes pnpm/pnpm#14431.

pnpm 11 reached Android because it was a JavaScript CLI and bionic was
irrelevant to it. pnpm 12 ships per-platform native binaries, which turned that
into a hard install failure.

This ships a dedicated artifact rather than aliasing the existing musl one onto
android. Aliasing would mean dropping `libc: ["musl"]` from the shared packages
and weakening that metadata for Alpine consumers, and it does not work anyway:
the musl binary's `getaddrinfo` reads `/etc/resolv.conf`, which Android does
not have and which cannot be created without root, so every registry lookup
fails there. A bionic binary resolves through Android's own resolver. The
generated manifests carry `os: ["android"]` with no `libc` field, since bionic
is neither of the two the field can name.

Cross.toml pins newer images for the two targets. The ones cross picks by
default carry NDK r21, whose toolchain predates Rust's move from libgcc to
libunwind, so linking fails with `cannot find -lunwind`. They are pinned by
digest rather than tag because they build published, attested binaries.

Android has no CI behind it: nothing in the release pipeline or the test suite
runs on a device or an emulator, so this is best-effort.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:35:46 +02:00
Zoltan Kochan 5adbbbe5e9 fix(config): route @jsr packages through the built-in JSR registry (#14653)
`Config::resolved_registries` is the map every scope-routed lookup picks a
registry from: the resolver, the lockfile's supply-chain policy verifier,
`pnpm why`, `pnpm view`, `pnpm audit`. It carried only the user's configured
scope routes plus the default registry, so an `@jsr/*` package fell through to
the default registry, which serves no such packument. Installing a lockfile
holding a JSR dependency then aborted with ERR_PNPM_META_FETCH_FAIL on a 404.

The built-in `@jsr` route was reachable in two narrower places only: the
resolver's `jsr:` specifier path, and `resolved_registry_declarations`, which
is what makes `pnpm config list` print a route the install disagreed with. The
TypeScript CLI puts the route in the scope map itself, which is why the same
lockfile installs there. Add it to the resolved map, where every consumer sees
it, with a configured `@jsr:registry` still winning.

The route stays out of `registry_declarations`, the shape a pnpr server is
told about, so npm.jsr.io is not put in front of its allowlist on requests
that resolve no JSR package. It stays out of the package-manager bootstrap
too: that map resolves the package manager alone.

pnpr consumes the same map, and its route allowlist — the resolver's SSRF
boundary — carried one built-in public route, npm, so a graph holding a JSR
dependency could not resolve through a default server. npm.jsr.io joins it:
pnpm reaches that registry without configuration, and it hosts no private
content. Both built-in routes now allowlist `https` alone, since that
allowlist also gates every redirect hop.

`pnpm changelog` picked its registry with a hand-rolled scope lookup that had
the same gap; it now calls `pick_registry_for_package` like every other
command.

Closes pnpm/pnpm#14649
2026-09-07 15:59:54 +02:00
Zoltan Kochan 06a9de0efb fix(patching): put patch-added build work through build approval (#14654)
A dependency's `requiresBuild` is read off its published files, which do
not carry the patch yet. A patch that adds `preinstall`, `install`, or
`postinstall` — or a `binding.gyp`, which the lifecycle runner turns into
an implicit `node-gyp rebuild` — therefore left the package looking
build-free: pnpm 12 never ran it and never offered it in
`pnpm approve-builds`, while pnpm 11 ran it without asking for approval
at all.

pnpm 12 previews the patch in memory (`pnpm_patching::preview_patch`)
while it builds `requires_build_map`, so the allow-build gate, the build
graph, `pendingBuilds`, and the side-effects cache key all describe the
package that ends up on disk. Deciding it there rather than after
`apply_patch_to_dir` is what makes the fix reach existing users: the key
derived from the pre-patch answer matches the entry a broken version
already wrote, and that cache hit would suppress the build forever on any
machine that had installed once. The preview answers for the package the
apply would leave: it chains repeated records for one file, drops a path a
later record deletes, resolves header paths the way `apply_one_file` does,
and reports the manifest verbatim so the caller's BOM-tolerant parser sees
what it would on disk.

pnpm 11 recomputes from the patched directory, where its filesystem-based
applier can reach it, through the new `dirRequiresBuild`, which reads the
same two triggers off a directory that `pkg_requires_build` reads in the
Rust stack. The recompute
runs even under `ignoreScripts`, because pnpm 11 must also stop writing a
side-effects entry for a package whose build is still owed: the entry the
patch alone produced would replay on the install that follows an approval,
and the scripts would never get their chance.

`manifest_requires_build` now reads a script's value rather than its key,
matching pnpm v11's `pkgRequiresBuild`. A manifest carrying an empty
`postinstall` runs nothing, and gating it would have asked the user to
approve a build that does not exist.

The preview is answered once per patch rather than once per snapshot: peer
variants of a package share both the extracted manifest and the patch, and
each preview reads and parses two files on the install path.

The preview also answers for a manifest a patch deletes and writes again,
tracks written paths as a set so a delete costs a lookup rather than a scan,
and `dirRequiresBuild` reports no build for a directory it cannot inspect,
as `pkg_requires_build` already did.

A header that spells the manifest in another case resolves through the
filesystem rather than by guessing the platform, so the preview reads the
manifest exactly where the applier writes it.

The preview is skipped for a package whose published manifest already
declares a build: a patch cannot subtract that, so reading and parsing the
patch would change nothing.

The two stacks agree on what a user sees. They still derive the
side-effects cache key differently for a patched package that runs
scripts, so such a package rebuilds on every pnpm 11 install, as it does
today.

Closes https://github.com/pnpm/pnpm/issues/14648
2026-09-07 15:58:36 +02:00
Zoltan Kochan 52859b9bfd feat: add freebsd-x64, linux-ppc64 and linux-s390x builds (#14656)
Build the Rust CLI and the NAPI addon for x86_64-unknown-freebsd,
powerpc64le-unknown-linux-gnu and s390x-unknown-linux-gnu, and publish them as
`@pnpm/exe.*` and `@pnpm/napi.*` platform packages, so installing pnpm on those
hosts resolves a native binary instead of failing the preinstall with no
prebuilt binary for the platform. Closes pnpm/pnpm#14597.

pnpm 11's npm package was a JavaScript CLI that ran wherever Node.js ran, so
all three worked without anyone building for them. pnpm 12 ships per-platform
native binaries, which turned that into a hard install failure. ppc64le and
s390x were the only two architectures Node.js ships an official binary for that
pnpm did not; both were reported on pnpm/pnpm#14651, which stays open for
OpenBSD, a tier 3 Rust target with no rustup std and no cross image.

The release matrix builds all three with cross on the existing x86_64 Linux
runner, the same way it already builds the aarch64 and riscv64 targets, so no
new hardware is involved.

The little-endian POWER package is `@pnpm/exe.linux-ppc64`, not `-ppc64le`.
Node reports both endiannesses as `ppc64` and npm's `cpu` field cannot express
which, so the name has to be what `process.arch` yields. Only the little-endian
build is released, matching Node.

FreeBSD's platform entry is a bare specifier rather than a glibc/musl pair. It
has no libc split, so it takes the same shape as darwin and win32 and never
reaches the libc ordering, and its manifest carries no `libc` field.
2026-09-07 14:57:22 +02:00
velonica0andZoltan Kochan 7a27a8efd9 feat: add linux-riscv64 build (#14529)
Build the Rust CLI for riscv64gc-unknown-linux-gnu and publish it as
@pnpm/exe.linux-riscv64, so installing pnpm on a riscv64 host resolves a
native binary instead of finding no prebuilt binary for the platform.

The release matrix builds the target with cross on the existing x86_64 Linux
runner, the same way it already builds both aarch64 targets, so no riscv64
hardware is involved.

PLATFORMS.linux gets a plain string specifier rather than a glibc/musl pair.
Only a glibc build is released, and the libc ordering maps over the pair's
keys, so a pair with a missing musl entry would yield an undefined specifier.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-07 12:30:44 +02:00
Zoltan Kochan 1aab19b559 fix(cli): wrap the Dependents column of outdated -r (#14639)
`pnpm outdated -r` sized the `Dependents` column to its widest cell. In a
workspace where one dependency is used by many projects, that cell lists
every project on one line, so the table came out hundreds of columns wide
and every row folded over several visual lines.

Clamp the column to 30 columns and wrap inside the cell at word
boundaries, matching pnpm 11's `getCellWidth(data, 3, 30)` in
`pnpm11/deps/inspection/commands/src/outdated/recursive.ts`.

`tabled`'s `Width::wrap` only shrinks a column, so a workspace whose
dependents fit under 30 columns still renders exactly as before. The
`ansi` feature is already enabled on `tabled`, so wrapping stays aware of
the SGR escapes the other columns emit. `--format list` and
`--format json` were never affected and are unchanged.

pnpm 11 already clamps this column, so this is a v12-only fix.

Closes pnpm/pnpm#14591
2026-09-07 12:16:38 +02:00
Zoltan Kochan 6e87cbf149 feat(pnpr): finish oci registry follow-ups (#14652)
Complete the OCI registry's remaining hosted and upstream workflows.

Reuse the verified streaming cache and publish journal. OCI batch entries
carry a base64 manifest and reference layers uploaded through /v2/ first.
Upstream tokens are cached by repository with bounded expiry and capacity;
credentials are restricted to the configured origin and trusted token service.

Bind short-lived OCI credentials to the original token's hash and the
addressed registry path. Check the original token's restrictions and current
registry policy on subsequent requests. Scoped tokens cannot access other
pnpr APIs, and mounts require pull scope for the source repository.

Maintain a separate filesystem package index, migrating legacy stores once
at startup. Catalog requests can find nested repositories without walking
through the parent's layer population. Prune index entries after removal
or failed creation. Uncached manifest probes use upstream HEAD, falling back to a verified GET
when legacy registries omit the digest header. Integrity failures abort shared
blob streams, and OCI batch validation preserves the original registry errors.

Explicit blob deletion reserves a marker in the repository document before
removing bytes. Increment its generation so a staged or recovered publish
cannot restore a manifest referencing the deleted content. An interrupted
deletion blocks new publishes until offline oci-gc finishes it. All replicas
must be upgraded before using online deletion. Filesystem stores retain
their existing exclusive-owner requirement.

Add regression coverage for scope and expiry checks, credential revocation,
cache poisoning, upstream authentication boundaries, cumulative upload
limits, nested catalogs, batch rollback, interrupted deletion, and a
manifest commit racing blob deletion on another replica.

Closes pnpm/pnpm#14630.
2026-09-07 11:51:31 +02:00
Zoltan Kochanandgithub-actions[bot] 08e64d4b83 chore: update dependencies, Node.js, pnpm, and GitHub Actions (#14603)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-07 11:29:41 +02:00
Zoltan Kochan 54d7268c10 fix(network): make fetch-timeout an inactivity timeout (#14642)
pnpm aborted a request once its total time passed `fetchTimeout`, body
included, so `pnpm runtime set node` could never finish its ~62 MB
download on a slow connection. pnpm v12 set reqwest's client-level
`timeout`, a deadline for the whole request; pnpm v11 passed
`AbortSignal.timeout` to undici, which aborts the response body too.

Both stacks now bound inactivity instead. pnpm v12 sets reqwest's
`read_timeout`, which restarts on every chunk received. pnpm v11 drops
the abort signal and lets the undici dispatcher enforce `headersTimeout`
and `bodyTimeout`, which behave the same way; the global dispatcher
sets per dispatch, through an interceptor composed onto the dispatcher
that would have served the request. The agents pnpm builds for a proxy
or custom TLS take their connect bounds from the same value, which the
abort signal used to cover.

A connection that stops delivering data still fails after
`fetchTimeout` in both.

Closes pnpm/pnpm#14604
2026-09-07 11:27:17 +02:00
Zoltan Kochan 7f3e8d9c66 fix(network): ignore CA material that carries no certificate (#14650)
`pnpm install` aborted with `Invalid CA certificate (entry 0)` whenever
a `ca` or `cafile` setting resolved to something that is not a readable
certificate. The armor check in `apply_tls` refused the entry outright,
so a blank `ca=`, a `ca=${VAR}` whose variable never resolved, or a
truncated bundle took the whole install down.

Node ignores CA material it cannot parse, which is why the same config
installs on pnpm 11: `tls.createSecureContext` accepts a garbage `ca`
and simply trusts nothing extra. Parse each entry with
`Certificate::from_pem_bundle` and add whatever it yields, so an
unusable entry contributes no trust anchor instead of failing the
client build. Dropping a root can only make verification stricter, so
this fails closed. `from_pem_bundle` fails a buffer whole, so an entry
it rejects is re-read block by block and every certificate that is
readable on its own is kept.

A blank `cert` / `key` now reads as unset for the same reason: pnpm
tests those fields for truthiness before handing them to undici, so a
blank one never reaches Node, while pacquet paired it with its
counterpart and let rustls reject the identity. The drop happens where
the identity is assembled, so a blank per-registry field keeps
shadowing its top-level counterpart the way pnpm's spread merge does,
rather than pairing a scoped half with a top-level half.

A `cert` / `key` pair that is present but invalid still errors, since
Node throws there too.

Closes pnpm/pnpm#14646
2026-09-07 11:26:07 +02:00
AdityaandZoltan Kochan a7c739f1c3 fix(patching): keep prefixes on added and deleted files, and apply preimage-less deletions (#14621)
`git diff --no-index` names both sides of a deleted file after the source
folder and both sides of an added file after the destination folder.
`normalize_diff_path_line` only matched the `a/` prefix against the source
folder and the `b/` prefix against the destination folder, so one side of
every added and deleted file fell through to the bare-folder fallback, which
also removed the `/` following the prefix. The header became
`diff --git a/x bx`, and the next `pnpm install` rejected the patch with
`ERR_PNPM_INVALID_PATCH`. Match every prefix against both folders before
running the fallback.

`patch-commit` runs `git diff --irreversible-delete`, which writes the header
of a deleted file without its preimage. The applier required a delete patch's
hunks to empty the file before unlinking it, so the patch `patch-commit` had
just written failed to install with `ERR_PNPM_PATCH_FAILED`. A delete block
that carries hunks is still verified against the file on disk; one without
hunks now unlinks on the header alone, as pnpm 11 does for every deletion.

pnpm v11 is affected by neither defect.

Fixes pnpm/pnpm#14559

---------

Signed-off-by: Aditya <asr.adityarathore@gmail.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-07 11:24:28 +02:00