Commit Graph
181 Commits
Author SHA1 Message Date
Zoltan KochanandClaude Opus 5.5 6f95957063 docs: lint prose with Vale (#17238)
* docs: lint prose with Vale

Add Vale with an in-repo style, .vale/styles/pnpm, that turns the
writing rules in AGENTS.md and the Google developer documentation style
guide into lint rules. The stock Vale packages (Google, Microsoft,
RedHat, write-good) were too noisy to block on, for example 1,300
passive-voice hits in the v12 docs alone, so the rules are written for
this repo, following the GitLab model. Each one links the guideline it
enforces.

Levels:
- error, fails CI: dashes, plain-word substitutions, condescending
  words, "please", "note that", hedges, puffery, optional plurals,
  exclamation marks, ellipses, curly quotes, generic link text, product
  spellings, sentence-case headings, repeated words, double spaces,
  implementation history in comments; in changesets, bold or italic
  emphasis, internal names and release-line names.
- warning: future tense, sentences over 30 words, "instead of" and
  "so that" tails in changesets.
- suggestion: passive voice.

.vale.ini scopes the rules. Docs get every rule. Contributor Markdown
and code comments keep dashes and headings as warnings until their
roughly 8,100 em dashes are rewritten. Released changesets stay in
.changeset/ and are not rewritten, so lint-prose.mjs lints only the
changesets added since PROSE_BASE (the PR base in CI).

The prose workflow installs a pinned, checksum-verified Vale release and
runs `pnpm lint:prose`. The rest of this change rewrites the existing
docs, READMEs, contributor Markdown, and code comments to pass the
error-level rules. Only prose and comment text changed. Renamed
headings keep their anchors.

Spelling stays with cspell, which checks identifiers that Vale does not
see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep prose lint from flattening meaning

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:48:09 +02:00
Zoltan Kochan 430d892741 feat(pnpr)!: serve every ecosystem under its own prefix (#17087)
pnpr served npm at the server root when npm was the only configured
ecosystem and moved it under /npm/ as soon as a second ecosystem was
added. Adding an ecosystem therefore broke every .npmrc registry URL,
every tarball URL already written into a lockfile, named-registry URLs,
and OIDC workload publish paths, and /npm meant the package `npm` on one
server and the ecosystem prefix on another. This settles section A1 of
pnpm/tasks#131: every ecosystem is always prefixed.

- Ecosystem::base_path is now independent of the configuration. The
  per-configuration Registries::base_path and is_only_ecosystem are gone,
  and the routes, the resolver's tarball URLs, OIDC workload paths and
  pnpr's own-origin route classification all use it.
- The default image registry still answers at /v2/ on the host root; an
  image client cannot be pointed at a prefix. The registry directory keeps
  its `prefixed` and `namedPrefixed` flags for the web UI.
- The read-only token allowlist matched the npm audit endpoints at the
  root only, so read-only tokens were already refused for audits on
  multi-ecosystem servers. It now matches them under the npm prefix.
- /metrics no longer switches between Prometheus metrics and the npm
  package `metrics` on the Accept header; the package is /npm/metrics.
- The pnpm v11 and v12 test harnesses, the registry-mock crate and the
  integrated benchmark address the fixture registry at /npm/ and keep the
  server origin for the pnpr protocol endpoints. The benchmark asks each
  revision mock's registry directory whether npm is prefixed, so it can
  still compare against a pnpr revision from before this change.
2026-10-18 16:55:55 +02:00
Zoltan Kochan cb5cc84ac8 fix(setup): succeed when no shell config file can be found (#17079)
`pnpm setup` treated ERR_PNPM_UNKNOWN_SHELL and ERR_PNPM_NO_SHELL_CONFIG
as fatal. In containers SHELL is usually unset, so setup exited non-zero
and the get.pnpm.io installer failed, although the pnpm binary and bins
were already in place.

Both errors now mean only "no shell config file can be updated". Setup
continues with the GitHub Actions env files and legacy shim cleanup,
then prints the reason and the PNPM_HOME/PATH lines to add manually, and
exits 0. Errors that indicate a real conflict or an unsupported shell
still fail the command.

Implemented in the Rust v12 CLI and the TypeScript v11 CLI with the same
message.

Related to pnpm/pnpm#4495, pnpm/pnpm#6217, pnpm/pnpm#16973.
2026-10-18 11:48:47 +02:00
Zoltan Kochan 8212de71e4 feat(skills): add defect-hotspots skill for recurring fix loops (#17021)
The skill finds areas where fixes keep landing and fixes need follow-up
fixes, then guides the analysis from those loops to the design flaw
behind them and the tests that would have caught them.

scripts/hotspots.mjs collects the evidence:

- fix commits per file and module, with fixes per thousand lines so
  size alone does not rank a module high;
- SZZ-style blame of each recent fix's removed lines, giving
  fix-on-recent-change and fix-on-fix edges and the commits that
  needed several follow-up fixes. Blame looks through commits that
  touch more than 30 source files, and edges into them are dropped,
  so file splits do not attract every later fix;
- issues opened in a recent window, linked to modules through the
  commits that close them.

implement-change now asks a bug fix to check whether the code it touches
was fixed recently and, if earlier fixes restored the same property, to
fix the cause or say why this fix is another case of it.
2026-10-17 12:05:04 +02:00
91c6d76d1b fix(fs-packlist): exclude npm-packlist's default ignore patterns from pack (#16971)
npm-packlist applies a set of default ignore rules in every directory it
walks, before that directory's own ignore files. pnpm 12's port of the
ignore walk never injected them, so `pnpm pack` and `pnpm publish` shipped
editor swap files, AppleDouble files, node-waf/node-gyp build leftovers and
archived-packages/.

The missing defaults from npm-packlist 10.0.4 are now soft ignore rules in
fs-packlist's walker (ignore_walk/default_rules.rs):

    /.lock-wscript  /.wafpickle-*  /build/config.gypi  .*.swp
    ._*  **/._*/**  **/.DS_Store/**  /archived-packages/**

They are parsed once and evaluated before each directory's own rules, at
every ancestor level, as ignore-walk's filterEntry does. Every child
directory gets them. The package root gets them only without a `files`
field, because npm's root `files` rules ('*' plus re-includes) override root
defaults entirely. They are not added to should_always_exclude or
is_pruned, so a `files` entry, main/bin/browser, or a negation can still
re-include a file, as in npm.

Evaluating eight rules at every ancestor for every entry made
`pnpm pack --dry-run` 3-6x slower on a 46k-file tree. The same walk serves
git and directory dependency imports. A per-segment gate now skips the
defaults for paths with no segment a default rule can match, so the
no-user-rules shortcut is kept. The default rules and the gate compare in
ASCII case only, as minimatch's `nocase` regexes for these patterns do: U+212A
(Kelvin) does not match `k`, so a name that matches only through Unicode
folding keeps shipping, as it does with npm. A test fails
if a pattern is added to DEFAULT_PATTERNS without a matching sample.

Closes pnpm/pnpm#16955

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-10-16 16:30:17 +02:00
Ben MaurerandZoltan Kochan b260a317ec feat: accept zstd-compressed responses and serve them from pnpr (#16585)
Enable reqwest's `zstd` feature and set `.zstd(true)` next to `.gzip(true)`
in the shared install client. pnpm now sends `Accept-Encoding: zstd,gzip,deflate`
(previously `gzip,deflate`) and decodes zstd bodies transparently.

Full packuments gain the most. A long field repeated across versions, such as
vite's `licenseText` (up to 259 KB per version, 34 MB of the document), spans
far more than gzip's 32 KB window, so gzip cannot reuse the earlier copies.
vite's full packument on registry.npmjs.org is 38.9 MB: gzip -6 gives 4.65 MB,
zstd -3 603 KB, zstd -19 455 KB. pnpm fetches full packuments for
`trustPolicy: no-downgrade`, for `resolutionMode: time-based` on registries
without a `time` field, and for `minimumReleaseAge` when the abbreviated
document has no `time`. Abbreviated packuments gain less (vite: 403 KB gzip,
382 KB zstd -3).

Brotli is left out on purpose. On these payloads it saves about as much as zstd
and would add a second decoder to the binary.

Tarballs are unaffected: registries serve them without `Content-Encoding`, so
integrity verification still hashes the raw `.tgz`. Because `reqwest` is a
workspace dependency, pnpr's upstream client gains zstd too. The WebContainer
transport gets a no-op `zstd()` builder method to match its `gzip()`.
pnpr also serves zstd: tower-http's `compression-zstd` lets its
CompressionLayer pick zstd when the client prefers it. Resolving 1,235
packuments against pnpr with full metadata moved 103.6 MB instead of
115.4 MB (-10%), and took 34.5 s instead of 38.4 s on a 25 Mbit/s link.
`zstd-sys` compiles the bundled libzstd with `cc`, which the build already
needs on every release target. The release binary grows by 152 KiB (+0.3%).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-10-15 21:35:31 +02:00
Zoltan Kochan c3d4af9b76 fix: fall back to fsync when F_FULLFSYNC is unsupported (#16858)
On Apple platforms `File::sync_all` issues `fcntl(F_FULLFSYNC)`, which
virtiofs and SMB shares reject with ENOTTY, so every atomic write on such
a share failed, starting with pnpm-lock.yaml. Add `pnpm_fs::sync_file`,
which retries with plain `fsync` on ENOTTY/ENOTSUP/EOPNOTSUPP/EINVAL (as
SQLite and libuv do), and route every pnpm v12 sync through it.

pnpm v11 is unaffected: libuv's fsync already has this fallback.

Closes pnpm/pnpm#16846
2026-10-11 21:20:59 +02:00
Zoltan Kochan 3b5af3f793 feat(pnpr): deprovision accounts through SCIM (#16742)
With `auth.scim.token`, pnpr serves the SCIM 2.0 `Users` endpoints and
discovery documents under `/-/pnpr/v0/scim/v2`, authenticated only by that
token.

An OIDC sign-in creates no account row and its session lives in one
replica's memory, so deleting an account is not enough to deprovision
anyone. The SCIM directory is therefore one record in the hosted store,
reloaded on the managed-state TTL like rosters and rules. Authentication
refuses any user identity the directory marks inactive, and so does the
npm login endpoint. Deactivating or deleting a user also revokes its
stored tokens, removes its password account, and drops this replica's
browser sessions for it.

A deleted user stays in the record as inactive and unlisted, so the
username keeps being refused until it is provisioned again. Provisioning
creates no password: the identity provider is the sign-in path.

Follow-up of pnpm/tasks#111.
2026-10-08 19:08:16 +02:00
Zoltan Kochan 68445b254a fix: harden dependency verification and package handling (#16613)
Verify configuration dependency registry resolutions before installing
plugins, with independently checked pinned tarballs. Require config dependencies to resolve from a registry and skip
registry verification when the installed config dependencies already match
the lockfile.

Constrain archive and deployment paths and preserve content-addressed files
when streaming package entries. Bound metadata allocation before the archive
parser buffers it, using an opt-in patch to the upstream tar crate. Bound
publishing manifest and README buffers without imposing a total extraction
disk quota.

Bind audit signatures to lockfile integrity, hash ambiguous URL dependency
directory names, and sanitize license terminal output. Preserve dependency
error context and structured fatal logs. Update affected documentation,
release notes, and regression coverage.
2026-10-05 22:09:01 +02:00
Zoltan Kochan 658dbc30a6 feat: support pnpm in StackBlitz WebContainers (#16499)
Compile the shared Rust CLI for wasm32-wasip1-threads and supply filesystem,
network, process, terminal and cancellation capabilities through a Node host.
Select host implementations at compile time so native builds retain their
native dependency/features and execution paths.

Bundle the WASM runtime in the regular npm wrappers and select it from the
WebContainer runtime marker. Keep Node launchers usable without installation
scripts. Native installation replaces all four launchers with native bytes.
Regenerate shims after upgrades or dependency builds replace Node targets with
native binaries, including before dependent lifecycle scripts run. Preserve
collision winners and publish replacement shims with executable permissions.
Reject non-descendant permission templates and intermediate symlink escapes, validate before creating directories, and avoid path-based cleanup races.
Restore direct native aliases in both version-management implementations.
Retain process cleanup after failed stdin shutdown. Prevent cross-origin redirects from replaying login credentials in both CLI lines, and strip npm-otp from cross-origin WASM requests. Avoid repeated selected-snapshot and removed-bin scans.

Preserve resolution, lockfile, integrity and build-approval behavior. Use an
isolated WASM store with rollback journals and process leases. Patch WASI path
normalization for multiple parent segments and retain that patch in bundles.

Add pinned tooling, release packaging, real-browser workflow and selection
tests, documentation and a release note. Document host limits, including the
Corepack cold-download failure in WebContainer's streaming hash implementation.
Controlled native CLI cases showed no separated slowdown, and the latest
hoisted rebuild and repeat-install fixture improved. Retain the documented
microbenchmark uncertainty and host limitations.

Related to pnpm/tasks#63.
2026-10-02 10:32:41 +02:00
Zoltan Kochan 4edeef9176 style(ts): port the perfectionist lint rules to ESLint (#16415)
Add TypeScript ports of the perfectionist rules that the Rust workspace
enforces through dylint.toml to @pnpm/eslint-config, with the same limits,
and fix every finding that needs no refactor.

The size and shape rules (function length, cognitive complexity, nesting,
local bindings, file length) are enforced for packages that already meet
them. The packages that do not are listed in PENDING_SIZE_AND_SHAPE_REFACTOR
in eslint.config.mjs and are refactored in follow-up changes.

The custom rules mirror perfectionist's semantics rather than reusing
sonarjs or max-lines-per-function, so that one limit means the same thing
in both codebases: a closure is a level of the body that contains it, a
nested function declaration is measured on its own, and a test-framework
callback plays the role of a #[test] function.

Bugs found and fixed along the way:
- a registry error body cut at exactly 64 KiB lost its truncation marker
- removing a dependency could leave dangling .bin shims
- constructor/__proto__ names crashed the projects graph, were skipped by
  the injected-deps syncer, and were dropped from writeWantedLockfile's
  returned lockfile

The CI chunk runner learns to run the node:test suite of the ESLint
config package.
2026-09-30 15:10:08 +02:00
91ff1680e5 fix: keep shared store ownership and group-write bits (#15882)
A shared store is group-writable and setgid. New files were opened with a
mode that dropped group-write, and new shard directories were created with the
process umask, so the next user could not write them. index.db was
created at 0644 and SQLite copied that mode onto the WAL sidecars.

Existing store files keep their owner and mode. New files open with the
parent directory's permission bits as a ceiling, then add back any bits
umask removed, so a default ACL wider than the directory mode survives.
New directories receive the ancestor's group permission and setgid bits
when it is group-writable or setgid.
Directories and files that already existed are not chmod'd. An explicit
private mode such as 0600 is not widened. pnpm does not chown or chgrp.
A new index.db is recognized by an exclusive create in both CLIs.

Closes pnpm/pnpm#12765
---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:56:37 +02:00
9f9472ff59 fix: reuse fresh metadata mirrors that have no etag (#15879)
Registries that omit ETag cannot answer If-None-Match with 304, so a warm
resolve re-downloads every packument. A public mirror younger than five
minutes and stored without an ETag is reused when the cached packument
already satisfies the range. A mirror marked uncacheable or dated in the
future is not reused. Registries that send an ETag still send a
conditional request. pnpm update still fetches current metadata.

Closes pnpm/pnpm#13976

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Gabriel Mari S. Flores <Shuuren@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:36:22 +02:00
d06937736c fix(lockfile): read the branch lockfiles of a detached HEAD (#15887)
With gitBranchLockfile, an install named its lockfile after the branch
HEAD pointed at. A detached HEAD, which is what a CI checkout of a
commit SHA produces, names none, so the install read only the shared
pnpm-lock.yaml and failed with ERR_PNPM_NO_LOCKFILE even though the
lockfile of a branch containing the commit sat next to it.

When HEAD is detached, the wanted-lockfile read now falls back to the
lockfiles of the local and remote-tracking branches containing the
commit, tried before the shared one. A CI checkout of a SHA usually has
only the remote-tracking ref it fetched, so those count too. The write target is unchanged: a branch containing HEAD need
not have HEAD at its tip, so a detached install still writes the shared
lockfile, as under mergeGitBranchLockfiles.

The fix covers both the TypeScript v11 CLI and the Rust v12 CLI.

Close pnpm/pnpm#7672


---------

Co-authored-by: gaishijie2025 <gaishijie@isrc.iscas.ac.cn>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:50:47 +02:00
Zoltan Kochan ad525f26db refactor: move the pnpm/components packages into the monorepo (#16214)
The remaining packages of the pnpm/components Bit workspace now live
under pnpm11/ and are published from here, keeping their npm names:

  @pnpm/config.env-replace           -> pnpm11/config/env-replace
  @pnpm/log.group                    -> pnpm11/log/group
  @pnpm/network.agent                -> pnpm11/network/agent
  @pnpm/network.ca-file              -> pnpm11/network/ca-file
  @pnpm/network.config               -> pnpm11/network/config
  @pnpm/network.proxy-agent          -> pnpm11/network/proxy-agent
  @pnpm/os.env.path-extender         -> pnpm11/os/env/path-extender
  @pnpm/os.env.path-extender-posix   -> pnpm11/os/env/path-extender-posix
  @pnpm/os.env.path-extender-windows -> pnpm11/os/env/path-extender-windows

lex-comparator and nerf-dart were already moved under new names, and the
Bit env component is build tooling with no counterpart here.

The sources are carried over from pnpm/components at 42ec0f7, which
includes the Windows fix that rewrites a PNPM_HOME value stored with the
wrong registry type. pnpm v12 got the same fix in pnpm/pnpm#15746, so
its changeset now also covers pnpm v11, which bundles the workspace copy.

Porting to the monorepo's ES module setup:

- relative imports carry .js and type re-exports are marked `type`;
- tests use jest.unstable_mockModule and import through the package;
- string.prototype.matchall and graceful-fs give way to built-ins;
- safe-execa and lru-cache move to the catalog versions (0.3, 11);
- network.config depends on @pnpm/config.registry-auth-key;
- the strict-ssl proxy test uses node:https and proxy v4 instead of
  node-fetch and proxy v1.

The packages start at 1100.0.0 to sit in the pnpm11 version band.
2026-09-26 23:33:47 +02:00
37227c0480 fix: explain SSH publickey failures when installing git dependencies (#15877)
Fetching a git dependency locked to an SSH remote now says to load a key
with ssh-add when git reports a publickey refusal, and still explains how
to re-record a pre-v11.21 entry with pnpm update.

Resolving an SSH URL that fails the same way shows that advice plus a
local HTTPS insteadOf rewrite when the remote logs in as git. The
rewrite uses a shell-safe host and no userinfo, so a password or a
hostile host cannot reach the command.

Closes pnpm/pnpm#13743

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:55:16 +02:00
79c2bfc352 fix: exit with the script's status after Ctrl+C (#15854)
dash holds a terminal SIGINT until the foreground command exits, then
dies from that signal even when the command already returned a status.
`pnpm run` treated that shell death as a lifecycle failure and lost the
script's code.

A Bourne shell now runs the script behind a trap that re-raises SIGINT
when the command itself died from it (status 130) and otherwise lets the
script continue, as bash does. A deliberate exit of 130 is
indistinguishable from that report, so it is re-raised too. A second
interrupt always re-raises, so a loop of builtins can still be stopped.

Closes pnpm/pnpm#9945

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:40:46 +02:00
69777a7b6f fix: refetch registry metadata when caching is forbidden (#15722)
When a metadata response carries Cache-Control max-age=0, no-cache or
no-store, the mirror's header line records uncacheable. The next install
that looks the package up online sends Cache-Control: no-cache without
If-None-Match or If-Modified-Since, so a stale intermediary cannot answer
it with a 304. Offline and prefer-offline still serve the mirror.
Registries that send a positive max-age keep conditional requests.

A 304 to a revalidation whose own Cache-Control forbids reuse is asked
once more without validators. Mirrors written before this change carry
no flag, so without that retry a stale intermediary would keep hiding new
versions from existing users until they pruned the store. A second 304
still serves the mirror.

The flag only governs the on-disk mirror. A packument fetched during an
install stays in the in-memory cache for the rest of that install, so a
registry that always sends max-age=0 is not asked again for every
dependency edge of the same package. Online paths that promote a mirror
into memory skip a flagged mirror, so memory never holds one.

If writing an uncacheable response to the mirror fails, the previous
mirror is deleted. Its header would otherwise carry validators into the
next request.

The TypeScript and Rust Cache-Control parsers agree: directive names are
case-insensitive, max-age is compared numerically, and a qualified
no-cache="field" does not forbid reuse of the document. Only response
headers decide cacheability; an uncacheable field in a registry body is
ignored.

Closes pnpm/pnpm#13487
---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:39:46 +02:00
Cody Tenney be9684b637 fix(release): keep Windows-only files out of Unix release archives (#15706)
Every per-target archive is built from one shared `dist/`, so the Windows
entries rode along into the macOS and Linux archives: the `node-gyp.cmd` batch
twin of the POSIX `node-gyp` wrapper, and the fastlist binaries `ps-list`
spawns from its Windows-only branch. In the macOS arm64 archive for 11.28.0
those two `.exe` files alone are 487 KB that no macOS process can load.

`copy-artifacts.ts` already drops the non-target `@reflink` native packages
per target, so the same pruning now covers these entries for every non-Windows
target. The Windows archives and the npm packages are unchanged: those serve
Windows, and the npm packages serve every platform out of one `dist/`.

The Rust CLI's GitHub archives carry `node-gyp.cmd` the same way, from a
node-gyp payload built once and shared by every target, so the non-Windows
archiving step drops it there too. Its npm packages keep it, as they do in the
TypeScript CLI.
2026-09-25 23:02:58 +02:00
d44a0978a6 fix: read gypfile: false as an opt-out of the implicit node-gyp build (#14900)
pnpm synthesizes a `node-gyp rebuild` install script for a package that ships a `binding.gyp` and declares no `install` or `preinstall` script. npm reads `gypfile: false` as that package's opt-out from the synthesized script (https://docs.npmjs.com/cli/v12/configuring-npm/package-json#gypfile). This commit implements that feature in pnpm so such a dependency needs no `allowBuilds` entry and is no longer listed under "Ignored build scripts". 

`better-sqlite3` v13 sets `gypfile: false` because it ships prebuilts on every supported platform. It's trying to avoid install script policy errors, and the dependencies required by node-gyp (python, c++). On pnpm, without this change, it triggers the script policy.

Because everything keyed off `requiresBuild` is decided before the build phase applies a patch, the patch preview answers for the whole patched package rather than for the paths the patch writes: a patch that drops `gypfile: false` puts the `binding.gyp` the package already shipped back in scope, and a patch that deletes the `binding.gyp` or the manifest takes the trigger, or the opt-out, away with it.

Store-index rows written before this change recorded a lone `binding.gyp` as build work, and their bundled manifest dropped `gypfile`. A stored `requiresBuild: true` whose only trigger is a `binding.gyp` and whose bundled manifest has no `gypfile` is rechecked against the package's `package.json` in the store, so a warm store picks up the opt-out without a refetch.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:22:34 +02:00
Altay 363129c613 fix(run): name the signal that killed a script (#15424)
A script killed by a signal has no exit code, so both CLIs reported
only "Command failed." pnpm v12 now reads the signal from the exit
status and prints "Command failed with signal SIGKILL." pnpm v11 keeps
the signal on the lifecycle error and the default reporter prints the
same title. The signal title also replaces the fixed "Test failed."
title of the test stage.

pnpm still re-raises the signal on itself afterwards, so the shell
keeps seeing an interrupted command. With v11, a signal that ends the
Node.js process on delivery (SIGKILL, SIGTERM) still ends pnpm before
the reporter prints.

Closes pnpm/pnpm#9821
2026-09-25 08:15:36 +02:00
AltayandZoltan Kochan 173edd5b42 fix(github-actions): apply minimumReleaseAge to action updates (#15556)
`pnpm outdated` and `pnpm update` with `--include-github-actions` offered
any tagged release of an action, however new, while the same commands hold
registry packages to `minimumReleaseAge`.

`git ls-remote` lists tags without dates, so when the policy is active the
tags newer than the version an action is on are fetched with
`--depth=1 --filter=tree:0` into a scratch repository, one fetch per
repository, and their creation date is read with `for-each-ref`: the tagger
date of an annotated tag, the committer date of a lightweight one. This
stays on git, so it works with any `update.githubActionsServer`.
Candidates newer than the current version are offered only when their date
is at or before the cutoff. `minimumReleaseAgeExclude` entries are matched
against the action and repository names, with exact versions supported.
A repository whose dates cannot be read is skipped with a warning, like one
whose refs cannot be listed.

Closes pnpm/pnpm#13923

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-24 22:09:44 +02:00
Zoltan Kochan 6d31f80014 fix(exe): propagate exit status from fallback Windows wrappers (pnpm/pnpm#14826) (#15547)
Update fallback `.cmd` and `.ps1` Windows wrapper templates in `@pnpm/exe`
to propagate exit status from the invoked `pnpm` command via `exit /b %errorlevel%`
and `exit $LASTEXITCODE`.

Add tests in `setup.test.ts` verifying that non-zero exit status (and successful
subcommand argument forwarding) is propagated properly from both wrapper types.

Closes pnpm/pnpm#14826
2026-09-24 22:05:32 +02:00
Zoltan Kochan 9e8d04e4b2 fix(pack): preserve symlinks in pack and deploy outputs (#15440)
Preserve internal symlinks in pack, deploy, and local directory imports.

pnpm v11:
- fs.packlist: npm-packlist drops every symlink. A PackWalker subclass
  (which also handles package.yaml/package.json5 manifests) keeps them,
  including symlinks named in `files` and those nested under a listed
  directory. Child directories are walked with the subclass too.
  packlistWithSources then drops links whose target leaves the package,
  checked in archive space (so `../<pkg-dir>/x` is rejected), on disk,
  and through realpath. Absolute targets are made relative first.
- releasing.commands pack: writes internal links as tar symlink entries
  and counts them as zero bytes. The publish manifest is always written
  as a file, even when package.json is a symlink.
- fs.indexed-pkg-importer: local-dir imports (deploy, `file:` dirs)
  recreate a symlink when its target stays inside the package. Links
  that escape, and file links whose target the import leaves out, are
  imported as the file they point to, as before. Store
  and remote imports skip the per-file lstat. On Windows without the
  symlink privilege a directory link becomes a junction to the final
  directory (the staged path is taken into account), and a file link is
  copied.

pnpm v12:
- fs-packlist: admits internal symlinks with the same archive-space
  check.
- pack: writes symlink entries, zero bytes in the unpacked size.
- directory-fetcher: a `preserve_symlinks` option lists a symlink by its
  own path, directory links included. Deploy and local directory installs
  set it. The injected-deps syncer does not, so its inode map is
  unchanged.
- deps-restorer: `preserve_symlinks` recreates validated links, in the
  isolated and the hoisted linker, for directory dependencies only. A file
  link whose target is not imported is imported as a file. Directory
  links fall back to a junction to the final directory, and file links to
  a copy, when Windows refuses symlinks.

Closes pnpm/pnpm#8208
2026-09-24 19:37:17 +02:00
Zoltan Kochan 58c313eb33 fix(resolver): install a dependency that is also an optional peer (#15503)
When autoInstallPeers is on, the resolver removed every name listed in a
package's peerDependencies from its dependencies, expecting the peer to be
auto-installed. Optional peers are never auto-installed, so a package that
both depends on something and declares it as an optional peer (vite's
lightningcss) ended up with neither. Such a dependency is now removed only
when an ancestor already provides it, the same rule autoInstallPeers=false
uses, so it keeps its own dependency range.

Fixed in both the TypeScript and the Rust resolver, with a new registry
fixture shaped like vite's manifest.

Closes pnpm/pnpm#8912
2026-09-24 15:47:36 +02:00
Altay 5b02bb130f fix(completion): complete bash words that contain a colon (#15368)
Bash splits COMP_WORDS at every COMP_WORDBREAKS character, so
`pnpm run test:u` reaches the completion function as `test`, `:`, `u`,
and readline replaces only the text after the last colon.

pnpm v12 passed the split words to the completion server unless
bash-completion's `_get_comp_words_by_ref` was loaded, so the server saw
`test` as the script argument and returned nothing. The bash script now
rejoins adjacent words from COMP_LINE itself.

pnpm v11 reads the command line from COMP_LINE and returned `test:unit`,
but bash inserted it after `test:` and produced `test:test:unit` unless
bash-completion's compatibility helpers were loaded. Both scripts now strip
the part of the current word up to its last word break from each candidate,
without depending on bash-completion.

Closes pnpm/pnpm#5482.
2026-09-23 21:41:55 +02:00
AltayandZoltan Kochan 30e3a83ed4 fix(exec): set package-manager environment variables (#15282)
Share lifecycle package-manager environment construction with exec and dlx
in both implementations. Child tools receive the invoking pnpm path, Node
path and initial working directory, matching npm exec.

Closes pnpm/pnpm#7037.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-22 23:05:23 +02:00
Zoltan KochanandClaude Fable 5.1 2af480acf4 fix: signal the script's process group when pnpm has no terminal (#15119)
* fix: signal the script's process group when pnpm has no terminal

A signal aimed at pnpm alone is the only kind that reaches it without
a controlling terminal, and both runners relayed it to the shell that
runs the script. A shell that stays the script's parent, as dash does
for a command like `node server.js`, dies from SIGTERM at once and
holds a SIGINT until its child exits, so the script never heard that
it should stop, and a container runtime then killed it outright.

Without a controlling terminal each script now gets a process group of
its own, pnpm relays its signals to that group, and after a relayed
signal it waits for the group to empty before ending, so the script's
shutdown completes even though its shell is gone. With a terminal
nothing changes: the child stays in the foreground group, where the
terminal's own signals reach it and it may read the terminal.

pnpm v12 decides this in spawn_child and waits in SpawnedChild::wait,
which the output pump now goes through too; members that became pnpm's
children, as they do when pnpm is PID 1, are reaped along the way.
pnpm v11 spawns the shell detached and polls ps for live members of
the group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(cli): decide the exec process-group test by the terminal it has

The process-group check ran under whatever terminal the harness had,
so it held on a developer's terminal and failed on CI, which has none.
It now runs pacquet on a pseudo-terminal for the foreground case and
in a session without one for the own-group case. The terminal helpers
of the interrupt tests move to the suite's utilities for that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(cli): run the remaining foreground-group checks on a terminal

The filtered-run and serialized-task process-group checks made the
same assumption as the exec one: that the harness holds a terminal.
They now run pacquet on a pseudo-terminal too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(executor): assert the foreground group only at a terminal

The unit test assumed the test process holds a terminal. It now
checks the contract for whichever case it runs in: a shared group at a
terminal, the child's own group without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(npm-lifecycle): probe the process group without ps, and raise the death signal after the wait

The group wait asked ps and took any failure for an empty group, so a
container without a usable ps cut the shutdown short. The group is
now probed the way the kernel counts it, with a signal of 0, and on
Linux a member that exited but is not reaped yet, as happens when pnpm
is a container's PID 1, is told apart through /proc.

A shell killed by the relayed signal made the runner raise that
signal on pnpm at once, before the group wait, and pnpm's own listener
was already consumed, so pnpm died while the script was still shutting
down. The raise now comes after the wait. An onSpawn observer that
throws signals the whole group too.

The tests observe the runner's exit rather than its output: a script
that outlives the runner keeps the shared pipe open, which hid this.
A failed setsid in the test helper fails the spawn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(npm-lifecycle): give up the group wait when the group cannot be observed

A probe error other than "no such process", such as a permission
error from a member that changed user, made the group count as live on
every poll, so the wait never ended. Such an error now rejects the
wait and the runner goes on. A /proc entry that vanished between the
listing and the read has exited; one whose state cannot be read counts
as live.

The runner's doc says that a script's death signal, raised after the
wait, ends pnpm before the callback unless something handles it. The
tests kill the detached process group whatever way they end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(npm-lifecycle): leave unreadable /proc entries out of the group

An entry whose state cannot be read is not a member pnpm can observe:
it exited since the listing, or it belongs to another user under a
restricted process table. Counting it as live kept the wait going on
every poll.

The test helper kills the detached group as soon as the runner has
exited, before waiting for the output pipe a survivor would hold open,
and its timeout kills the group rather than the runner alone. The
cleanup helper is shared through `@pnpm/prepare` and accepts only a
group that is gone already. Two narrating comments in the Rust tests
are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(npm-lifecycle): cover the process-table scan with a table of its own

The scan takes the process table's path, so a test can supply one with
a zombie, a live member and an entry whose state cannot be read, and
show that the wait ends for the first, goes on for the second, and does
not mistake the third for a member.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(npm-lifecycle): helpers after their use, and a bump for the shared cleanup helper

The process-table test's helpers follow the tests that call them, and
the one that writes the table states what it creates and returns. The
changeset lists `@pnpm/prepare`, which gained the shared cleanup helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(npm-lifecycle): keep only the non-obvious part of the table helper's doc

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(npm-lifecycle): clear the losing deadline timer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:48:10 +00:00
Zoltan Kochan 4eb09c84e5 refactor: move npm-lifecycle into the pnpm monorepo (#15113)
Import the lifecycle runner from pnpm/npm-lifecycle at 178f3d5 as
@pnpm/exec.npm-lifecycle, so it is maintained alongside its consumer
and a fix no longer needs a separate release.

The source is converted to TypeScript with typed options, package
data, log and child-process interfaces, which replaces the any-typed
declaration in __typings__/local.d.ts. The legacy pkg._data unwrapping
and the unused opts.config are dropped. The tests are ported to jest.

The node-gyp wrappers ship with the package; the runner finds them
beside lib in the package and in dist once the CLI bundle has
flattened the module, where the CLI ships its own copy. The manifest
updater keeps node-gyp-bin in the package's files. @yarnpkg/shell's
declarations import micromatch's, so a packageExtensions entry gives
it @types/micromatch. The Artistic-2.0 license is preserved.

The imported code includes the fix for the terminal SIGINT being
relayed to a script that already had it (pnpm/npm-lifecycle#62), which
this brings to pnpm v11; a pseudo-terminal test covers it in the
package and in the CLI.

Related to pnpm/pnpm#7374
2026-09-19 13:01:50 +02:00
Zoltan Kochan b1094872fa fix(cli): restore package script completion (#15086)
The completion server previously offered only candidates from clap's command
and option definitions, leaving the run script position empty.

Initialize clap metadata before scanning boolean options and register the
run-script alias. Track positional arguments and directory options in the
completion context.
Reuse the npm project prefix lookup and manifest reader to return sorted script
names. Share workspace-root lookup with command execution, preserve explicit
directories, and respect equals-only options. Escape zsh candidate separators
after prefix filtering. Read manifests only at the run script position, before configuration or
hooks are initialized, and propagate malformed manifest errors.

The missing-script regression affects v12 only. The TypeScript v11 implementation
already reads scripts from the project manifest. A related Bash glob expansion
bug affects both versions and is fixed in both generated scripts. Preserve
candidate lines without splitting or pathname expansion, quote Bash candidates
for insertion, escape PowerShell
candidate syntax, and normalize its quoted prefixes. Reuse the short-option
scanner so combined directory and workspace-root flags select the same project
as command execution.

Closes pnpm/pnpm#15034.
2026-09-19 02:58:18 +02:00
Efe Baran DurmazandZoltan Kochan 9c1e317110 feat(cmd-shim): make project bin shims relocatable (#15041)
Project bin shims embedded absolute NODE_PATH entries and target markers,
and .bin/node used an absolute symlink. Moving or copying the project
could leave binaries resolving dependencies from the original location.

Add relocatable_root to LinkBinsOptions. On Unix, write in-root
NODE_PATH entries and target markers relative to the shim directory,
and make in-root node runtime links relative. Apply the same option
when injected dependencies are relinked.

Resolve the shim directory physically with cd -P and pwd -P for both
absolute and relative invocations. Node normalizes parent segments
lexically, so an unresolved directory symlink can send NODE_PATH
outside the project. This adds a subshell for project shims using a
physical directory anchor. Escape the relative segments for
double-quoted sh strings and stop if the directory cannot be resolved.

Upgrade existing absolute in-root node links before the warm-install
shortcut. Preserve already-relative links without recreating them.

Keep Windows rendering and out-of-root shim behavior unchanged.
Resolve bin directories, target parents and extra NODE_PATH entries before
computing relative paths, including symlinked and not-yet-created directories.
Broader moved-node_modules support remains outside this PR.

Related to pnpm/pnpm#6937.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-19 01:40:59 +02:00
871ca5f9b6 feat(cli/add): package urls (#14949)
`pnpm add` reads a `pkg:` selector as the Package URL specification
defines it and routes it to the ecosystem its type names: npm to
package.json, cargo to Cargo.toml, and pypi to pyproject.toml. Each
purl is rewritten into the selector that ecosystem's own protocol
already takes, so it inherits that path's flags, defaults, and errors
instead of growing a second set.

Every decoded component is validated before the pieces are joined back
together. A name followed by a spec is how pnpm spells an npm alias, so
a name or a version carrying a decoded at-sign or colon would otherwise
redirect the install to another package. Qualifiers and subpaths are
rejected rather than dropped: a repository_url qualifier or a subpath
that pnpm ignores installs something other than what the selector names.

AddArgs::package_names now holds the selectors the npm add path receives
rather than the ones argv carried, which is what lets a purl reach the
--global and --config forms as well. AddPipeline keeps only the selectors routed
to another ecosystem, so the split has a single home, and
dispatch_install::add delegates that split to a named step to stay
within the local-binding limit perfectionist enforces.

A purl always names a package to install. pnpm add node@22.0.0 records a
runtime and pnpm add npm@11.0.0 records the project's package manager,
both from a bare name a person typed, so the package-manager and runtime
recognition skips a selector a purl was rewritten into.

That mark belongs to the request rather than to its text. package_names
holds AddRequest values, each carrying its own selector and whether a purl
was rewritten into it, so two requests that normalize to one selector keep
their own meanings and no second list has to be matched against or kept in
sync.

A rejected selector is quoted back by what it decodes to, because an
encoded separator would otherwise hide a user:pass@ authority from a check
made on the raw text. What decoding leaves unreadable to that check is
stopped at instead: a colon followed by an at-sign is how a URL spells
userinfo, and nothing pnpm installs pairs the two past its protocol, so no
list of the characters that can sit between a scheme and its slashes has to
be complete.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-18 14:51:39 +02:00
Zoltan Kochan a9ee095ca7 feat(python): lock for several platforms and Python versions (#14970)
`pylock.toml` was resolved for the machine that ran the install: one
marker environment, one wheel tag order, one wheel per distribution. A
lockfile a repository commits could therefore not serve Linux CI and
macOS or Windows contributors at once, which every repository surveyed in
pnpm/pnpm#14945 needs.

`python.platforms` and `python.pythonVersions` now name the environments
`pylock.toml` is resolved for. Every platform is paired with every
version, and a list left empty is the platform or the version of the
interpreter running the install, so declaring neither keeps the previous
behavior. Each environment is resolved on its own and the answers are
merged into one lockfile: a package pins the wheel every environment
takes for it and carries the PEP 751 `marker` saying which of them
install it. `[tool.pnpm]` records the declared environments, so changing
them resolves the project again.

The interpreter reports what a declared environment resolves as, through
the same host helper that reports its own tags and markers. A platform is
named as a Rust target triple, or as an architecture and the libc
baseline its wheels are built against; `linux`, `macos` and `windows` are
short names for the most common three. An architecture the triple spells
differently from the wheel tag is translated, and a baseline outside the
glibc 2 or musl 1 series is refused. Two names for one environment are
one environment, told apart by what the interpreter reports them as.

`pnpm install` narrows the lockfile to the environment its interpreter
matches: it installs the packages that environment's marker selects and,
for each, the pinned wheel whose tags the interpreter prefers. An
interpreter none of the declared environments stand for is refused, since
nothing in the lockfile answers for it.

A declared environment answers for a platform and a Python version and
nothing else. A requirement whose marker it leaves undecided is refused
rather than locked under a claim the resolution did not make. That covers
a requirement gated on the kernel release, and one gated on a patch
release of a minor version declared without one.

Declared environments are resolved locally, since a pnpr server answers
for one interpreter. An index page is fetched once per run and read back
from its cache for every environment after the first.

Separately, the rule that decides whether a lockfile's marker names the
full interpreter version counted only release segments, so a locked
package requiring `<3.12.0.post1` was claimed for every 3.12 patch
release. A bound whose version falls between two releases now tells them
apart.

Related to pnpm/pnpm#14945.
2026-09-16 16:26:43 +02:00
Zoltan Kochan 417601da4a fix(python): read an unparsable Requires-Python as none at all (#14920)
Every file an index lists is parsed when a distribution's candidates are
read, so one release publishing a `Requires-Python` that is not a version
specifier aborted the whole resolution. `openpyxl` 3.0.0 through 3.0.7
publish `>=3.6,`, which put `openpyxl` out of reach of every project, at
any version: a release is immutable, so nothing the project does can
correct the value.

An unreadable `Requires-Python` is now read as if the release declared no
interpreter range, which is what pip does. All three readers of the field
agree on that: the index page a candidate comes from, the wheel metadata a
resolution steps through, and the marker a solved lockfile writes.

Closes pnpm/pnpm#14910
2026-09-15 11:42:32 +02:00
Zoltan Kochan 224aed744b refactor: move cmd-shim into the pnpm monorepo (#14893)
Import cmd-shim source, tests, and snapshots from pnpm/cmd-shim at
83f9b9449c2adeec26e1cc2d03ef0fe82d2e41ca. Maintaining the shim alongside
its consumers removes the need for coordinated changes across repositories.

Publish the package as `@pnpm/bins.cmd-shim` and replace external dependencies
with workspace links. Preserve the original BSD-2-Clause license, including
its copyright notice, and keep the manifest updater from replacing it with MIT.
Reuse `@pnpm/fs.graceful-fs` and integrate the existing Node.js tests with the
workspace test scripts. Retain the existing snapshots and align the version
with the TypeScript workspace's required 1100.x band.
2026-09-14 21:30:59 +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 3610b85e30 feat(pnpr): serve container images (#14629)
Adds OCI as a fourth ecosystem beside npm, Cargo, and Python: a hosted
image registry that docker, podman, and skopeo push to and pull from.

The distribution API cannot be moved under a path prefix. A client
derives the API root from the image reference's host, so
`pnpr.example.com/acme/app:1.0` always requests
`/v2/acme/app/manifests/1.0`. `/v2/` therefore mounts at the host root
whatever else is served, and the repository name alone selects the
registry through the declared-provenance rules the other surfaces use.
Artifactory has to burn the first path segment as a repository key for
want of that invariant; pnpr does not, so the image name stays the image
name. `/oci/v2/` and `/oci/~<name>/v2/` are served too, for podman and
containerd, whose registry configuration does accept a path.

Repository names are `/`-joined, which the pattern language had no shape
for. `PackagePattern::parse` now takes the ecosystem and offers each one
only the wildcard its names can carry: npm keeps `@scope/*` and `@*/*`,
images get `<namespace>/*` over a single leading component, and Cargo
and PyPI get neither, since a flat name could never match one. A
`packages:` key is normalized through that language rather than through
the name rules, so a wildcard key parses as itself.

Blob uploads stream to a local file across requests instead of buffering
in memory, and are verified against the promised digest before anything
is stored. The manifest write is the commit point and rides the existing
publish journal; blobs land outside it, content-addressed and invisible
until a manifest names them, which is what leaves unreferenced blobs for
a collector rather than half-publishing a release. A tag carries the
time it last moved, so a transaction recovered after a crash cannot drag
one back to an older manifest.

`Basic` credentials now carry a token as the password under any
username, which is how `docker login` sends them, and `GET /v2/`
challenges an anonymous caller even where reads are open: a client
settles its authentication scheme on that one response, so a 200 would
leave it no way to authenticate a push.

Verified end to end against docker 29.7.2 (login, push, pull, run),
podman 5.8.4, and skopeo 1.22.2, plus an anonymous pull and a push
refused after logout. The two client stacks take different upload paths
and between them cover both: moby sends a blob monolithically, while
containers/image chunks it.

Not yet served: proxying an upstream image registry, blob collection,
the referrers API, cross-repository blob mounts, and ranged blob
downloads.
2026-09-07 03:49:35 +02:00
Zoltan Kochan 6ba99fb386 feat(pnpr): share cargo compilation caches (#14620)
Expose named compiler caches through the WebDAV subset used by sccache.
Apply pnpr account access and publication policies before buffering uploads,
including existing token restrictions. Entries are immutable and verified
against a digest bound to their cache scope, key, and bytes before serving.
Reuse the shared artifact store's filesystem/S3 selection, quota accounting,
publication lifecycle, and orphan reclamation.

Bound compiler uploads to two concurrent bodies per server and reject excess
requests before buffering. Store digest and payload as separate buffers in
one conditional object write. Use metadata-only HEAD and duplicate checks;
GET still verifies content before serving it.

Stock sccache trusts the server and allowed publishers; it does not verify
pnpm signed envelopes. Document trusted CI publication and HTTPS requirements.
Also document sccache 0.17's absolute Rust build-path requirement and its
read-only multilevel behavior: remote hits backfill disk, but new compilation
misses are not cached locally when a tier is read-only.

Add HTTP, policy, integrity, quota, and real Cargo integration coverage.
Install sccache for Rust CI and local test setup. Extract the existing
miette diagnostic normalization into a shared test helper to fix a shim
assertion exposed by the full suite's longer temporary paths.
2026-09-06 15:35:06 +02:00
Zoltan Kochan 09b546affe feat(pnpr): publish across ecosystems in one transaction (#14608)
`PUT /-/pnpr/v0/publish` takes a batch whose entries each name their
ecosystem and carry what that surface's own publish endpoint takes, with
the binary parts base64-encoded. An entry that names none is an npm
publish document, so a body the npm batch endpoint accepts is already a
valid one.

The address is in pnpr's own namespace rather than beside the npm batch
endpoint, which is part of the npm surface and answers under `/npm/`
with it. `GET /-/pnpr` advertises the protocol as `publish: [0]`, and now
answers for a registry-only tier, which has a pnpr protocol of its own
for the first time.

Every entry is parsed, authorized and verified before any of them is
staged, every blob is staged before any of them is committed, and the
commit is the single journal transaction the publish flow already had:
a release that spans ecosystems becomes visible all at once. The
per-ecosystem publish endpoints keep their own behavior; they and the
batch now share one staging path, `StagedPublish`, which carries the
ecosystem of the package it staged.

Closes one checkbox of pnpm/pnpm#14599.
2026-09-06 11:48:33 +02:00
Zoltan Kochan 1e83fc5f8b feat(pnpr): serve Cargo and Python registries beside npm (#14598)
Add Cargo and Python registry protocols to pnpr. Concrete registries declare
an ecosystem, and routing filters mixed router sources by the requested
protocol. Ecosystem prefixes provide default and named registry endpoints
while preserving the original npm aliases.

Reuse hosted document and blob storage, package access rules, upstream
metadata caching, and verified artifact streaming across the new surfaces.
Keep upstream artifact cache identities bound to metadata checksums. Check
metadata before cache lookup so changed or removed entries take effect.

Honor package-specific Cargo privacy rules when advertising auth-required.
Require authentication before reading Python upload bodies. Bound multipart
boundaries and use the existing regex crate for delimiter searches, checking
boundary suffixes so binary lookalikes remain part of the uploaded file.
Reject Cargo archive traversal, links, oversized decompressed streams, and
manifests whose package identity differs from the publication metadata.
Reject package configuration keys that collide after normalization.

Apply route allowlists to Cargo and Python metadata and artifact fetches,
including redirects. Require secure same-origin destinations for configured
headers. Rebuild headers on every approved redirect so cross-origin metadata
and artifact downloads succeed without forwarding upstream credentials.
Share this redirect loop with the existing network metadata fetcher and retain
its request-budget guard while response bodies are read. Transfer permits to
streamed artifact responses so body consumption or cancellation releases them.
Keep one pnpr timeout budget across redirects and the final response body.
Stream through a bounded producer whose deadline runs independently of
downstream polling. Reject hosted Python blobs absent from publication metadata.

Abort publication on an immutable object-store conflict and remove the
conflicting staged file. The filesystem backend remains single-writer;
replicated deployments use the object-store backend. Journaled publication
across all registry surfaces remains tracked in pnpm/pnpm#14599.

Add protocol unit tests and HTTP regressions for publication, routing,
authentication, content negotiation, caching, and integrity validation.
2026-09-06 09:25:23 +02:00
Zoltan Kochan 427bece813 feat(cli): integrate python with shared install and artifact lifecycles (#14586)
Exercise the multi-ecosystem architecture through a usable Python
integration, not test-only metadata writers.

Keep Python requirement, marker, lockfile and environment semantics
separate from npm and Cargo. Reuse the install-wide HTTP/auth budget,
verified artifact ingestion, CAS and store index.

Extract a shared install lifecycle without ecosystem-specific branches.
Native tasks declare metadata footprints and return prepared projections.
Settle all work before publishing Cargo or Python state, and reverse
attempted publications before restoring metadata on failure.
Retain resources when rollback fails so recovery remains possible.

Enroll npm with its existing in-place materialization semantics, and
keep npm-only dispatch on its early path. Do not unify native resolvers,
lockfile formats, target identity or package layouts.

Consolidate archive ingestion below the ecosystem boundary. Share cache
validation, authenticated requests, extraction retries and publication
across tarballs and ZIPs, retaining tar streaming and ZIP decoding.
Test the shared contracts across both formats and preserve npm fast paths.

Use standard pylock.toml, independently validated with uv.
New features target pnpm v12 only. Shared archive URL-redaction fixes
also cover pnpm v11.

Related to pnpm/pnpm#14566 and pnpm/rfcs#34.
2026-09-05 22:58:05 +02:00
Zoltan Kochan 19ba4d4bfd fix(network): use the system resolver on Linux (#14471)
On Linux the install client used reqwest's hickory-dns feature. Hickory
parses /etc/resolv.conf with the resolv-conf crate, which rejects the
whole file on any `options` token it does not recognise, including the
`no_tld_query` spelling glibc accepts as an alias of `no-tld-query`.
reqwest's Hickory glue swallows that parse error and silently builds a
resolver against Google's 8.8.8.8/8.8.4.4, so pnpm never contacted the
configured nameserver: fetches failed wherever public DNS is blocked and
private registry names leaked to the public internet.

Use the capped native getaddrinfo resolver on every platform, as macOS
and Windows already do, and drop the hickory-dns feature. The system
resolver is what pnpm 11 (Node's dns.lookup) and every other client on
the host use, it honours nsswitch.conf sources Hickory bypasses, and it
has no public-DNS fallback. The four-lookup cap that mirrors libuv's
thread pool stays.

Removing the feature drops hickory-resolver/-proto/-net, resolv-conf and
moka from the dependency graph, which also retires the two hickory-proto
advisory ignores in deny.toml.

Closes pnpm/pnpm#14469.
2026-09-02 17:29:32 +02:00
Zoltan Kochan a83487c2f8 perf: reuse store content when restoring a remote build (#14189)
Reuse store content when restoring a remote build.

A remote side-effects restore downloaded every file of the artifact, then handed the bytes to the store, which addresses content by the very digest the artifact manifest already carries. A built package's files are mostly its own, and artifacts share files with one another, so most of what was transferred was content the store already had.

Ask the store first. `locateFileInStore` answers with the path it holds for a digest and mode, or nothing, and the restore only fetches what is missing. The store keeps executable and non-executable content apart, so the mode is part of the question.

Nothing is trusted that was not trusted before: the store addresses content by its hash, so a file it holds under an artifact's digest is that artifact's bytes.

The pacquet side already had the store helper this needs; its test pins the part that could silently drift — a digest or executable-bit rule diverging between the write side and the artifact side would not fail anything, it would just make the lookup always miss.

Follow-up to pnpm/pnpm#14171.
2026-08-27 18:11:52 +02:00
Zoltan Kochan e7f3bccfff feat: workspace task orchestration (#14209)
Replace the chunked topological scheduler of recursive run/exec with
per-task scheduling in both stacks: a task — a (project, script) pair —
becomes runnable when every task it depends on has completed
successfully, and runnable tasks are dispatched under the
workspace-concurrency limit with no barrier between
dependency-independent tasks.

A new "tasks" section in pnpm-workspace.yaml declares task dependencies
with the caret convention ("^build" = the task in each workspace
dependency, "build" = the task in the same project). A task with no
entry behaves as depending on its own name in the workspace
dependencies, which is exactly what chunking implied. A project without
the script becomes a pass-through node that is reported skipped and
keeps the chain intact.

Also per the RFC: task-graph cycles are ERR_PNPM_TASK_CYCLE naming the
participating tasks, scoped to the invocation's selected graph, with
ignoreWorkspaceCycles: true downgrading the error to a warning;
--resume-from excludes exactly the anchor's transitive dependencies;
--reverse runs the reverse graph; under --no-bail dependents of a
failed task are reported skipped and do not add to the exit code; with
--bail, the first failure ends the run at once and nothing new is
dispatched; output is inherited only when at most one script can ever
be in flight; and pnpm -r run --dry-run [--json] prints the resolved
task graph without running anything (the verify-deps check included).

Implementation: the projects sorter exposes the tunneled dependency-edge
map (filteredProjectsDependencies / filtered_projects_dependencies)
instead of only its flattened chunks; the recursive summary is
task-keyed (dependsOn-pulled tasks get "<dir>#<task>" keys); pacquet's
inert --sequential now means concurrency 1 and its --no-sort
resume/reverse handling is aligned with the TypeScript CLI; the dead
chunk helpers are removed.

Related to https://github.com/pnpm/rfcs/pull/23
2026-08-27 02:41:03 +02:00
Zoltan Kochan 83350dea56 feat: integrate the remote side-effects cache with install (#14171)
Integrate the remote side-effects cache proof of concept with dependency installation in both TypeScript pnpm and pacquet.

Plan eligible build candidates before contacting pnpr, batch lookup requests, verify the configured P-256 trust keys and artifact integrity, hydrate verified files into CAFS, and select the restored side-effects map before lifecycle scripts run. Fall back to the ordinary local build on all cache failures.

Configure the feature through the remoteSideEffectsCache setting, assembled by the config reader from the workspace file, the global config file and the environment. A repository declares which organization and packages are eligible and nothing else: everything describing the act of signing stays with the machine holding the key, so a cloned repository cannot turn that key into a signing oracle.

Capture and publish the actual post-build diff from trusted builders. Bound artifact hydration and blob downloads, retain CAFS paths instead of downloaded buffers, and withhold direct store writes from a store opened read-only. Add transactional owner and global storage accounting to pnpr alongside request, response, timeout, streaming-memory, manifest, variant, descriptor, lock-namespace, concurrency, and crash-residue bounds.

Keep the proof of concept organization-scoped and Linux glibc-only. Defer lockfile pinning, persistent quarantine, publisher ownership, and key lifecycle policy to the RFC.

Related to pnpm/rfcs#20.
2026-08-26 15:49:39 +02:00
Zoltan Kochan 66398009c7 feat(config): honor the settings the Rust CLI only recognized (#14109)
Section 4 of the parity sweep in pnpm/pnpm#14101: settings present in
pacquet's `types` table — so `pnpm config` handled them — that no code
read.

- `updateNotifier`: pacquet had no update notifier at all. `install` and
  `add` now ask the project's registry for pnpm's `latest` tag once a day,
  emit `pnpm:update-check`, and the default reporter prints the notice when
  that version is newer. The cadence lives in `<stateDir>/pnpm-state.json`
  under `lastUpdateCheck` — the same file, key, and `toUTCString` format
  pnpm writes, so the two CLIs share one throttle. The check is
  best-effort: an unreachable registry or an unwritable state dir cannot
  fail the install. Two deliberate gaps: an `install` finishing through
  the repeat-install fast path returns before the async runtime exists and
  is not covered, and the "to update, run" line never names `@pnpm/exe` —
  the native binary is published under both names with no marker telling
  them apart, and a global install of either replaces the other.
- `legacyDirFiltering`: `use_glob_dir_filtering` was hardcoded on.
- `initAuthorName` / `initAuthorEmail` / `initAuthorUrl` / `initLicense` /
  `initVersion`: `pnpm init` now writes them over the scaffold's
  placeholders, in place, so the key order is unchanged. The TypeScript
  reader dropped `init-version` from the `PNPM_CONFIG_*` env schema to
  keep the two `init` implementations aligned; that exclusion goes away
  with this commit.
- `maxsockets` was inert in *both* stacks, not just in pacquet: the
  reader folded npm's lowercase spelling into `maxSockets` before any
  config file or env var had been applied, and `.npmrc` stopped carrying
  the key when the non-auth settings moved to yaml. The fold moves after
  the env loop, and pacquet gains the alias in both the settings file and
  the environment. The default stays `None` in pacquet (uncapped per
  origin, bounded by `networkConcurrency`) rather than npm's 50, which
  would cap it below the network concurrency it already resolves.

`npmPath` is the fifth item on that list and needs no port: nothing reads
it in the TypeScript CLI either, since publishing stopped shelling out to
the npm CLI. Its one remaining mention — a `Pick` in `recursivePublish`
that no code consumes — is dropped, so the deadness is visible.

The pacquet command harness turns the notifier off for every suite: no
test may reach the registry for pnpm's own `latest` tag or record the
check in the developer's state directory, which the suites leave
un-isolated.

Related to pnpm/pnpm#14101.
2026-08-23 16:39:49 +02:00
Zoltan Kochan bf46078c19 fix(deploy): drop excluded dependency groups from the deployed project (#14069)
A shared-lockfile deploy prunes the package graph to the included dependency
groups but kept every direct dependency in the deployed package.json and in the
deployed lockfile's importer. The deployed lockfile therefore referenced
packages it did not define, and the next install in the deploy directory tripped
over that: pacquet linked the missing packages and left dangling symlinks, while
the TypeScript CLI refused the install with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY.
A --prod deploy of a project whose dev workspace dependency no longer exists
pointed the importer at a missing directory the same way.

Fill only the included groups into the deploy importer, in pacquet's
create_deploy_files and in the TypeScript CLI's createDeployFiles, so the
manifest, the importer, and the package graph agree. The graph walk no longer
needs its own include gating for the importer maps; the snapshot-level optional
handling from pnpm/pnpm#13641 stays as it is.

Also drop an emptied group from pacquet's deploy importer instead of writing it
as `{}` — the TypeScript lockfile writer omits an empty dependency map, so the
two stacks were producing different deploy lockfiles for the same input.

Resolve pacquet's --prod/--dev/--no-optional flags the way the TypeScript CLI
does: --prod wins over --dev, and a dev-only install drops optional dependencies
along with the production ones. Without it `pnpm install --dev` installed an
optional dependency pnpm skips, and the deploy change above would have carried
that divergence into `pnpm deploy --dev`'s manifest and lockfile.

Closes pnpm/pnpm#13623
2026-08-22 16:37:00 +02:00
Pratik DulalandZoltan Kochan 03fd4caa00 fix(pack): write POSIX ustar headers for packed tarballs (#13925)
Fixes https://github.com/pnpm/pnpm/issues/13924

## Summary

- Write tar entries in the full POSIX ustar header form npm and pnpm 11 emit: `Header::new_ustar` for the `ustar\0` magic and `00` version, plus an explicit `0` regular-file typeflag (the typeflag byte stays NUL otherwise).
- Add a regression test that pins the raw typeflag, magic, and version header bytes, since tar readers accept every header form and decoded values would not catch a format regression.
- Add the required pacquet patch changeset, and `typeflag` to the cspell dictionary.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-08-21 03:10:47 +02:00
Zoltan Kochan daecf8765f perf(link): prefer hardlink over clone in Auto on Linux (#14012)
Bun materializes the same warm-store `node_modules` in roughly a third of pacquet's wall time on btrfs. Profiling the gap showed it was never syscall mechanics — it was the tier `Auto` picks: pacquet reflinks there, and a reflink is a new inode plus extent bookkeeping inside the filesystem's metadata trees, where a hardlink is one directory entry and an nlink bump.

## Measurements

alotta-files fixture (39k files), warm store + lockfile, cold `node_modules`, btrfs, interleaved A/B:

| | clone-first (before) | hardlink-first (after) |
|---|---|---|
| 32 threads | 0.91s wall / 5.4s sys | **0.53s / 2.9s** |
| 4 threads | 1.14s / 1.5s | **0.66s / 0.9s** |

Hardlink-first is also the default Bun ships (`--backend=hardlink`).

## Scope: pnpm 12 only

This changes what the default materializes on disk, so it ships behind the v12 major. **pnpm 11's TypeScript importer deliberately keeps clone-first** — the two `Auto` implementations intentionally diverge on this until pnpm 11 is retired. The ladder's rustdoc and the changeset record the decision, and the changeset bumps only `pacquet`.

## What doesn't change

- **pnpm 11**, entirely.
- **ext4** (GitHub CI, the published benchmark): `FICLONE` is unsupported there, so `Auto` always ended up hardlinking after one failed reflink. Nothing moves.
- **macOS** keeps clone-first — APFS `clonefile` is the platform's cheap primitive.
- Explicit `packageImportMethod: clone` / `clone-or-copy` / `hardlink` / `copy` are untouched.

## The trade

Clone-first bought store isolation on Linux CoW filesystems: a clone can't be corrupted by a package that mutates its own files at runtime. But every ext4 and Windows install already runs without that isolation, and the store's real guard is `verify-store-integrity`. v12 makes Linux stop paying extra for a protection the other platforms never had; users who want the isolation keep it with `packageImportMethod: clone`.

## What was tried and rejected

Bun's other structural difference — `linkat` from open directory fds (a 256-entry store-prefix fd table plus a per-package dirfd) instead of absolute-path resolution — was implemented and benchmarked too. Every link was confirmed on the fd path (counted: 35k+ hits, 0 fallbacks), kernel time fell ~15%, and wall **regressed** (link phase 335ms → 531ms at 4 cores). Linux resolves hot cached paths through the lock-free RCU dcache walk; the fd anchoring saves nothing that was expensive. Making the per-package file loop sequential also regressed (straggler tail on thousand-file packages). Both reverted; noting it here so nobody re-walks that path without new evidence.

## Implementation

The downgrade cache moves from `fetch_max` (which encoded the ladder in the constants' numeric order) to a compare-exchange step along a per-platform ladder (`next_auto_tier`), so racing rayon workers still converge without a lock. `pnpm:progress imported` telemetry reports the platform's ladder head instead of unconditionally claiming clone (a review catch). Tests pin the ladder order, the fresh-state hardlink, the telemetry mapping, and that a stale compare-exchange can neither skip nor regress a tier.
2026-08-19 15:09:53 +02:00
cakeniandZoltan Kochan e5007150fa fix: refetch metadata when publish times are incomplete (#13750)
Detect partial per-version publish-time maps before applying minimumReleaseAge.

Some registries provide timestamps for only a subset of releases in abbreviated packuments. Re-fetch full metadata unless every version has a publish timestamp, so mature versions remain resolution candidates. Keep the TypeScript and pacquet implementations and regression coverage in sync.

Closes pnpm/pnpm#13741.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-08-19 11:25:27 +02:00