Commit Graph

46 Commits

Author SHA1 Message Date
Zoltan Kochan
93458600a8 chore(release): 11.8.0 (#12492)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-18 12:17:52 +02:00
Juan Picado
ff9a1cfa1d feat(pnpr): per-uplink verdaccio settings (maxage, timeout, max_fails, fail_timeout, cache) (#12375)
Add verdaccio's per-uplink tuning knobs to pnpr's proxy uplinks, parsed
from the same config.yaml shape verdaccio uses:

- maxage:       per-uplink packument freshness window; overrides the
                global packument_ttl when set, otherwise defers to it.
- timeout:      per-request deadline applied to every upstream fetch
                (default 30s).
- max_fails /   circuit breaker: after max_fails consecutive failures the
  fail_timeout  uplink short-circuits with a 503 until the fail_timeout
                cooldown lapses, then one probe per window is allowed
                through (defaults 2 / 5m). max_fails: 0 disables it.
- cache:        when false, tarballs stream through without being written
                to the local mirror (default true).

Interval values accept verdaccio's format ("2m", "30s", "1h30m", or a
bare number of seconds, as either a YAML string or a numeric scalar);
an unparsable or out-of-range value is a named InvalidConfig error
rather than a silent fallback or a panic. Defaults match verdaccio
(timeout 30s, max_fails 2, fail_timeout 5m, cache true).

Circuit breaker behavior:
- Only a 5xx or transport error counts as a failure; a non-404 4xx
  (auth / rate-limit / bad request) surfaces verbatim and never opens
  the circuit.
- The half-open probe is gated on the cooldown window, so exactly one
  probe runs per fail_timeout and a cancelled or dropped probe cannot
  stick the breaker open.
- An open breaker reports the configured uplink name, not its upstream
  URL, so the 503 does not disclose internal endpoints.

Part of the verdaccio-parity tracking issue pnpm/pnpm#11973.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-17 23:44:03 +00:00
Khải
7e8abe26a0 refactor(pacquet): replace OnceLock with LazyLock where init is self-contained (#12477) 2026-06-17 18:06:54 +00:00
dependabot[bot]
de74c58f58 chore(cargo): bump object_store from 0.12.5 to 0.13.2 (#12354)
* chore(cargo): bump object_store from 0.12.5 to 0.13.2

Bumps [object_store](https://github.com/apache/arrow-rs-object-store) from 0.12.5 to 0.13.2.
- [Changelog](https://github.com/apache/arrow-rs-object-store/blob/main/CHANGELOG-old.md)
- [Commits](https://github.com/apache/arrow-rs-object-store/compare/v0.12.5...v0.13.2)

---
updated-dependencies:
- dependency-name: object_store
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(pnpr): import ObjectStoreExt for object_store 0.13 method move

object_store 0.13 moved get/put/delete off the ObjectStore trait onto
the new ObjectStoreExt trait. Import it so the S3 hosted-store backend
keeps compiling.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-17 11:37:36 +00:00
Zoltan Kochan
c112b6106b feat(install): add --dry-run option (npm-style preview) (#12449)
## Description

Adds a `--dry-run` option to `pnpm install` with **npm-style preview semantics**: it runs a full dependency resolution and reports what a real install **would** add/remove/update, but writes **nothing** to disk (no lockfile, no `node_modules`, no `.modules.yaml`, no workspace-state file) and **always exits 0**.

```
$ pnpm install --dry-run
Dry run complete. A real install would make the following changes (nothing was written to disk):

Importers
.
  + is-negative 1.0.0

Packages
+ is-negative@1.0.0
```

When the lockfile is already up to date it prints `Dry run complete. pnpm-lock.yaml is up to date; a real install would make no changes.`

Resolves https://github.com/pnpm/pnpm/issues/7340.

### Why this shape

An earlier attempt (#12270, now closed) implemented `--dry-run` as `--frozen-lockfile --lockfile-only` — i.e. a fail-on-drift *lockfile validator*. That collides with the well-established meaning of `--dry-run` across npm/yarn ("preview, never fail") and duplicated existing behaviour (`pnpm install --frozen-lockfile --lockfile-only` already does that). This PR implements the intuitive preview meaning instead.

### How it works (pnpm)

- Reuses the existing `lockfileCheck` callback (resolve fully, skip the lockfile write, hand back the before/after wanted lockfile) plus `lockfileOnly` (skip `node_modules`, the workspace-state file, and metadata-cache writes).
- The frozen/headless fast path is disabled whenever `lockfileCheck` is set, so a check-only install always resolves and never materialises anything.
- The before/after lockfiles are diffed (reusing the dedupe diff engine, now exported as `calcDedupeCheckIssues`) and rendered into the report.
- `--dry-run` with a configured pnpr server is rejected (that path resolves/links through the server).

### Pacquet

Ported in the second commit — `pacquet install --dry-run` forces the fresh-resolve path, skips every write (a new `dry_run` flag on `InstallWithFreshLockfile` skips the `pnpm-lock.yaml` save), and a new `dry_run` module diffs the existing lockfile against the freshly-resolved one and prints the same report.
2026-06-16 19:12:56 +02:00
Abdullah Alaqeel
817f99dbe5 fix(resolver): stabilize transitivePeerDependencies in dependency cycles (#12286)
## Summary

Fixes lockfile churn where a package's `transitivePeerDependencies` (e.g. `supports-color` via `debug`) could be dropped — and shift between packages — when the package participates in a dependency cycle. Which packages carry a given transitive peer depended on resolution order, so upgrading an unrelated dependency churned the lockfile.

## Root cause

When peer resolution walks into a cycle, the cycle is broken by dropping the repeated package's subtree, so the re-entry occurrence resolves against truncated children and looks peer-free. That occurrence was then recorded as "pure" in `purePkgs` — a verdict keyed by package id, not by context. A later occurrence of the same package, reached through a different parent that *can* see the full subtree, hit the `purePkgs` short-circuit and returned an empty peer set, dropping the transitive peers it should have surfaced. Because the outcome depends on which occurrence is walked first, it was order-dependent.

## Fix

Don't record a cycle re-entry's resolution in `purePkgs` / `peersCache` (a re-entry is detected when the package id already appears in the ancestor chain). Its truncated peer sets aren't authoritative for the package as a whole, so leaving the caches untouched lets later occurrences resolve correctly — or reuse the package's authoritative, non-truncated entry via `findHit`. This is a minimal guard at the cache-population site: it adds no post-resolution pass and does not change `transitivePeerDependencies` for packages that aren't in cycles.

This PR also includes an independent fix: when collecting peer providers from a node's children, match each child's resolved package name in addition to its alias, so `pnpm add my-alias@npm:real-pkg` is visible to peer resolution when `real-pkg` is a peer dependency name.

Both the TypeScript pnpm CLI and the Rust (pacquet) port are updated in parity.

Fixes pnpm/pnpm#5108

Related `transitivePeerDependencies`-instability reports: pnpm/pnpm#5552, pnpm/pnpm#5794, and the `transitivePeerDependencies` aspect of pnpm/pnpm#9992 (the out-of-scope version drift in pnpm/pnpm#9992 is a separate problem and is not addressed here).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-16 13:39:37 +00:00
Zoltan Kochan
62bb228e46 fix(package-manager): keep catalog: reference and operator on update --latest (#12423)
pacquet update --latest only handled catalog: dependencies under the
strict/prefer catalogMode. Under the default manual mode the catalog
reconciliation was skipped entirely, so the resolved version was written
straight into package.json, destroying the catalog: reference (and the
catalog entry was left stale).

Handle a --latest bump of a catalog: dependency for every catalogMode:
keep the catalog: reference in the manifest and rewrite the catalog
entry in pnpm-workspace.yaml, preserving the entry's own range operator
(via which_version_is_pinned on the catalog specifier, mirroring pnpm
substituting the catalog spec into the bareSpecifier before calcRange).

The effective catalogs are read lazily (ensure_catalog_ctx), preferring a
post-updateConfig pnpmfile hook output (config.catalogs) over the on-disk
pnpm-workspace.yaml read, matching Install::run; only read when a catalog:
dependency is actually bumped or a non-manual catalogMode needs
reconciliation, so no-op paths (a compatible bump, an unmatched --latest
selector) never touch the catalogs.

Add an in-memory catalogs_override to Install so --no-save still bumps the
lockfile for a catalog: dependency: the merged catalogs drive resolution
without being persisted to disk, matching how a non-catalog --no-save
update updates the lockfile.

Verified against pnpm 11.7.0: update --latest on a catalog: dep keeps the
reference and bumps the entry with its operator preserved (~1.0.0 ->
~3.0.1), regardless of catalogMode.
2026-06-15 15:16:28 +02:00
Zoltan Kochan
1e82e001cd chore(release): 11.7.0 (#12414) 2026-06-15 08:37:08 +02:00
Zoltan Kochan
681b593eb2 fix: support scope-specific registry auth tokens (#12392)
pnpm can now use different auth tokens for different package scopes, even when those scopes use the same registry URL.

Previously, auth was selected only by registry URL. If `@org-a` and `@org-b` both used `https://npm.pkg.github.com/`, they had to share the same token. This caused problems for registries that issue tokens per organization or per scope.

Configure a scope-specific token by adding the package scope after the registry URL in the auth key:

```ini
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/

//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}

//npm.pkg.github.com/:_authToken=${FALLBACK_TOKEN}
```

`pnpm login --registry=https://npm.pkg.github.com --scope=@org-a` writes the token to the same scope-specific auth key.

When installing or publishing `@org-a/*`, pnpm uses `ORG_A_TOKEN`. For `@org-b/*`, pnpm uses `ORG_B_TOKEN`. Packages without a matching scope continue to use the registry-wide fallback token.
2026-06-14 11:43:30 +02:00
Zoltan Kochan
94c13cc068 ci: run clippy as a single-OS job and add it to the pre-push hook (#12389)
* fix(pnpr): pass batch_publish test request bodies by reference

The put_json/put_json_with_token test helpers took the JSON body by
value but only borrowed it for serde_json::to_vec, tripping clippy's
needless_pass_by_value under --all-targets. Take &Value instead, which
also drops an unnecessary body.clone() at one call site.

* ci: run clippy as a single-OS job and add it to the pre-push hook

Clippy was a step inside the three-OS Lint-and-Test matrix, so it ran
once per OS even though it lints the same platform-independent source
each time. Move it to its own ubuntu-only job, mirroring the existing
single-OS doc, format, and dylint jobs (platform-gated cfg blocks are
still type-checked per-OS by the test build).

It was also missing from pacquet/scripts/pre-push-rust.sh, so a clippy
lint that only fires under --all-targets — like the one that just
reached main — slipped past local pushes and surfaced only in CI. Add
the same --all-targets workspace clippy gate to the hook.
2026-06-13 20:23:16 +02:00
Zoltan Kochan
f1521cfc8c feat(publish): add --batch flag to publish all packages in a single request (#12299)
* feat(publish): add --batch flag to publish all packages in a single request

When publishing recursively, the new opt-in --batch flag packs every
selected package first and sends them all to the registry in one
"PUT /-/v1/multi-publish" request per target registry, instead of one
request per package. The endpoint is not part of the standard npm
registry API; registries that lack it are reported with
ERR_PNPM_MULTI_PUBLISH_UNSUPPORTED.

pnpr implements the endpoint with all-or-nothing semantics: every
document is validated (name, publish policy, attachment integrity) and
every tarball is fully written to a tmp slot before anything becomes
visible, so a batch that fails midway leaves no new versions behind.
The single-package publish handler was refactored into shared
validate/stage/commit steps, and package-lock stripes are acquired in
sorted order so overlapping batches cannot deadlock.

* refactor(publish): namespace the multi-publish endpoint under /-/pnpm/v1/

Vendor-namespace the batch publish endpoint as /-/pnpm/v1/multi-publish,
mirroring how the npm client keeps its registry extensions under
/-/npm/v1/. The unprefixed /-/v1/ namespace is effectively owned by the
npm registry API (login, search, tokens), so a future npm endpoint at
the same path could collide with different semantics. The pnpm prefix
makes the path unambiguously a pnpm-client extension while staying
server-implementation-neutral.

* refactor(publish): rename the batch endpoint to /-/pnpm/v1/publish

The endpoint is not inherently about multiple packages — a single
package is just a batch of one — so name it after the operation rather
than the cardinality. The unsupported-registry error is renamed to
ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED to match the other BATCH_PUBLISH_*
error codes.

* feat(pnpr): crash-atomic publish commits via a journal

A publish (single-package or batch) is applied in several non-atomic
steps — one rename/upload per tarball, one packument write per package
— so a crash mid-apply could leave a batch partially published. Before
anything is promoted, the full intent (merged packument bytes + staged
tmp-file locations) is now persisted under .pnpr-journal/<txn>/ and
sealed with a single atomic rename of the commit marker. Startup
recovery rolls sealed transactions forward (every apply step is
idempotent, and the packument is re-merged into the current on-disk
state so versions published between a failed apply and the restart
survive) and rolls unsealed ones back, so a publish is either fully
visible or fully absent.

* test(pnpr): pin canonicalization of scoped dist.tarball URLs on serve

libnpmpublish-based clients submit dist.tarball with the scoped
filename (.../-/@scope/name-1.0.0.tgz). The registry never serves that
string verbatim: rewrite_dist_tarball rebuilds the URL from the
basename, so consumers always see the routable 4-segment form. The
batch publish scoped test now submits the real client wire form and
asserts the served packument exposes the canonical URL.

* test: align getConfig env-var warning test with request destination blocking

Project-level .npmrc request destinations (registry=, proxy keys) no
longer go through env expansion at all — they are dropped with a
dedicated warning — so registry=${VAR} can't produce the generic
'Failed to replace env in config' warning anymore. Exercise the
env-replace warning through cafile= (still expanded) and pin the
request-destination ignore warning in its own test.

* fix(pnpr): harden crash-atomic publish commit against partial states

Two reliability fixes from PR review:

- Journal recovery treated any I/O error probing the commit marker as
  "unsealed" (`try_exists(...).unwrap_or(false)`), so a transient error
  could roll back an already-committed transaction and delete its staged
  tarballs. Propagate the error instead, so startup fails loudly rather
  than risk losing a sealed publish.

- After sealing, the commit loop applied packages with `?`; a mid-loop
  failure returned an error while earlier packages were already visible,
  leaving a running server partially published until the next restart's
  recovery. On apply failure, complete the sealed transaction immediately
  via the same idempotent roll-forward, falling back to the original
  error with startup recovery as the final backstop.

* fix(pnpr): fail recovery loudly when a staged tarball can't be probed

roll_forward treated any fs::try_exists error on a staged tarball as
"missing", so a transient I/O error would skip promotion, still write the
packument, and delete the journal entry — advertising a tarball with
nothing on disk and no journal state left to retry from. Propagate the
error instead, mirroring the commit-marker probe, so recovery aborts and
the transaction survives for a later attempt.

* test: fix env-var warning assertion in the cafile getConfig test

cafile=${ENV_VAR_123} asserted the registry request-destination warning,
which is only emitted for registry/proxy URLs; cafile is still
env-expanded, so an unresolved placeholder surfaces the generic
"Failed to replace env in config" warning. Assert that instead and
retitle the test (the request-destination case has its own test).

* fix(pnpr): serialize package deletes with the same package lock

delete_package and delete_tarball mutated package storage without taking
the per-package lock that every publish and dist-tag path holds, so a
same-package DELETE could race a stage-and-commit and remove the package
or a tarball mid-write, leaving the on-disk state dependent on filesystem
timing. Acquire the lock before the removal, completing the same-package
serialization guarantee.

Also bind the batch-publish test stub URL to 127.0.0.1 to match the
listener, avoiding intermittent IPv6 connection failures.
2026-06-13 19:16:40 +02:00
Zoltan Kochan
4819fb4e66 fix(pacquet): match pnpm lockfile resolution (#12372)
## Summary
- Match pacquet peer-resolution and lockfile output to pnpm for transitive optional peer variants.
- Apply pnpm's Yarn compatibility package extensions in pacquet, with `ignoreCompatibilityDb` support.
- Add regression coverage on both the pnpm resolver test and pacquet resolver/install paths.

Fixes pnpm/pnpm#12330.
2026-06-13 15:07:59 +02:00
Zoltan Kochan
1310ab53c4 perf: close repeat-install and warm-resolve gaps (lazy lockfile, pre-runtime fast path, 304 freshness renewal) (#12364)
* perf(cli): keep the repeat-install fast path off the lockfile parse and thread spawns

The "Already up to date" short-circuit decides from manifest mtimes
alone (mirroring upstream checkDepsStatus, which never reads the wanted
lockfile), yet pacquet parsed pnpm-lock.yaml eagerly in State::init
before the check ran — a multi-millisecond YAML parse on every no-op
install, scaling with lockfile size (babylon's 720 KB lockfile dominated
its repeat-install wall time).

pnpm-lock.yaml now loads through a LazyLockfile (OnceLock-backed) that
install forces only after the fast path has passed on the run; add /
update / remove / outdated / the pnpr path force it up front, keeping
their behavior unchanged. The repeat-install regenerate branch probes
for the file's existence instead of its parsed contents, so the
fast path stays mtime-cheap.

The rayon global pool is likewise no longer built eagerly at startup:
the worker count is published via RAYON_NUM_THREADS (set in fn main
while the process is still single-threaded) so the pool spawns lazily
on first parallel use — commands that never reach a parallel phase no
longer pay 2×CPUs thread spawns.

A corrupt lockfile now surfaces its parse error when the install
actually reads the file; an up-to-date project with an unreadable
lockfile reports "Already up to date" exactly as pnpm does.

* perf(cli): finish up-to-date installs before building the async runtime

A plain `pacquet install` that is already up to date now completes on
the main thread, before the tokio runtime, the rayon pool, the HTTP
client, or any install state exists. The new
`install_already_up_to_date` twin of the repeat-install short-circuit
reuses the exact same workspace discovery and
`check_optimistic_repeat_install` inputs as `Install::run`, and the
CLI invokes it from the (now synchronous) `main` after clap parsing.

Gates mirror everything that would make the full path behave
differently: `--frozen-lockfile` / `--lockfile-only`, a configured
pnpr server (that path never runs the optimistic check), `--recursive`
/ `--filter`, config dependencies, and pnpmfile updateConfig hooks
(both can mutate the config the check compares against). Any gate or
error falls through to the full install path, which re-runs the check
and reproduces failures with their established error shape; the
"Already up to date" + summary emissions are byte-identical.

Repeat-install instruction count on the vue fixture drops from ~203M
to ~41M retired instructions — within a rounding error of the
`pacquet --version` floor (~39M).

* perf(resolving): renew metadata-mirror freshness on 304 Not Modified

The minimumReleaseAge freshness shortcut treats a metadata mirror
younger than the cutoff as authoritative and resolves without touching
the network. But a 304 revalidation never rewrote the mirror file, so
its mtime froze at the last 200 response: once a cached packument grew
older than minimumReleaseAge (24h by default), every subsequent install
re-validated every package against the registry, forever.

A 304 proves the cached packument equals the registry's current
document, so the validation clock legitimately restarts at the
response: bump the mirror's mtime to now (fire-and-forget — a
read-only cache dir only costs the next install another conditional
request). Applied to both stacks: pnpm's pickPackage notModified
branch and pacquet's fetch_full_metadata_cached 304 path.

On a vue-fixture install with a stale cache, the second warm resolve
drops from ~2s (520 conditional requests) to ~250ms (zero requests).

* style(resolving): use clippy's preferred Duration units in the 304 mtime test

CI clippy denies duration_suboptimal_units; from_hours / from_mins
replace the hand-multiplied from_secs values.

* style(package-manager): use clippy's preferred Duration unit in the sync fast-path test

Same duration_suboptimal_units deny as the previous commit, one site
CI's clippy surfaced after it stopped at the first failing crate.

* fix(lockfile): address review — dir-addressed LazyLockfile, read-open 304 touch

LazyLockfile resolved pnpm-lock.yaml against the process cwd while the
CLI honours a canonicalized --dir without chdir, so the deferred load
and the existence probe could consult a different lockfile than the
rest of the install (which derives lockfile_path from the manifest's
directory). The lazy handle now carries the manifest's directory and
loads via load_wanted_from_dir; lockfile-disabled config gets an
explicit disabled() constructor. The pre-runtime fast path builds its
handle from the same directory, keeping verdict parity with
Install::run.

renew_mirror_freshness opened the mirror with append just to bump the
mtime; set_modified only needs a file handle plus ownership (futimens
semantics), so a plain read-open also covers mirrors whose mode
dropped write permission.

* test(integrated-benchmark): compare best-of-N samples in the slow-start proxy test

The test raced two single wall-clock samples, and a loaded CI runner
can inflate the ramped-vs-flat comparison in either direction (macOS
runner measured flat 318ms vs ramped 304ms against a ~66ms model).
Scheduler stalls only ever inflate a sample, while slow start's ramp
overhead is structural and survives in every sample — so the minimum
of several runs per side is the noise-resistant estimator.

* chore(deps): bump esbuild to 0.28.1 to clear GHSA-gv7w-rqvm-qjhr

The new advisory (install-module RCE via NPM_CONFIG_REGISTRY,
patched in 0.28.1) fails the audit gate. 0.28.1 was published within
the minimumReleaseAge window, so the patched version is excluded from
the age gate — the same mechanism pnpm audit --fix uses — including
its '@esbuild/*' platform packages, whose versions move in lockstep
with the root package.

* fix(lockfile): make the unloaded presence probe match the loader's absence rules

The loader treats an empty file and an env-only combined document as
an absent wanted lockfile (Ok(None)), but is_loaded_or_on_disk probed
bare file existence, so the repeat-install path could skip restoring a
semantically-missing pnpm-lock.yaml. The probe now reads the file and
checks the main document is non-empty (Lockfile::wanted_exists_in_dir)
— the loader's exact absence rules, still without the YAML parse.

* fix(cli): build the rayon pool after the fast-path gate instead of injecting env

Publishing the worker count through RAYON_NUM_THREADS leaked the
variable into every child process the install spawns — lifecycle
scripts, node probes, git — and pnpm exposes no such variable to
scripts. Build the global pool with ThreadPoolBuilder again, but only
once the repeat-install fast path has declined: real installs pay
exactly the cost they always did, the no-op path still spawns no
workers, and the process environment stays untouched (which also
drops the unsafe set_var and its single-threaded contract).

* fix(lockfile): treat only NotFound as absence in the presence probe

A permission or I/O failure reading pnpm-lock.yaml reported the file
as absent, which would send the repeat-install path into the
regenerate-on-missing branch — overwriting an existing lockfile it
merely could not read. Only NotFound counts as absent now; any other
read failure reports presence, and the real load surfaces the
underlying error when the contents are actually needed.

* fix(resolving): open the mirror write-capable for the 304 touch, read-only as fallback

Windows' set_modified requires write-attributes access on the handle,
so the read-only open silently failed there (caught by the Windows CI
run of a_304_renews_the_mirror_mtime). Append-mode open carries that
access; the read-only fallback still covers Unix mirrors whose mode
dropped write permission, where timestamp syscalls need ownership
rather than write access.

* fix(package-manager): never short-circuit partial installs as already up to date

add and remove mutate the manifest in memory and persist it only after
Install::run returns, so the on-disk mtimes the optimistic
repeat-install check reads still describe the pre-mutation project.
With a fresh workspace state, `pacquet add X` right after a clean
install reported "Already up to date", skipped the entire install,
and then saved a package.json declaring a dependency that was never
resolved, lockfiled, or materialized (self-healing on the next run,
which sees the newer manifest mtime).

Gate the short-circuit on is_full_install, mirroring upstream
installDeps calling checkDepsStatus only for the plain-install
mutation, never for installSome / uninstallSome. The new
partial_install_disables_optimistic_short_circuit test fails without
the gate.

The bug predates this PR (the KeepAll gate has carried add since the
optimistic path landed) — surfaced by CodeRabbit review on
pnpm/pnpm#12364.
2026-06-13 00:10:06 +02:00
Zoltan Kochan
93e974a7ce test(deps.compliance): use synthetic package in audit update test (#12368) 2026-06-12 23:57:11 +02:00
Zoltan Kochan
c16eb0a154 perf: run lockfile verification concurrently with frozen install (#12227)
## Problem

`pnpm install` with a frozen lockfile got noticeably slower because lockfile verification blocks every later install stage. The verification gate (the `minimumReleaseAge`/`trustPolicy` policy revalidation plus the tarball-URL anti-tamper check) issues a registry round trip per lockfile entry, and the whole install waited for it to finish before any fetching or linking could begin.

## Change (pnpm / TypeScript)

Run lockfile verification **concurrently** with fetching and linking instead of blocking on it, while keeping two guarantees intact:

1. **No lifecycle script runs on an unverified lockfile.** A `verifyLockfile` gate is threaded into both `buildModules` call sites — `headlessInstall` (frozen path) and `_installInContext` (full-resolution path) — and awaited immediately before any dependency lifecycle script runs. The projects' own `preinstall`/`postinstall` hooks are held to the same gate at both `runLifecycleHooksConcurrently` call sites, covering the `enableModulesDir: false` path that skips the build phase. If verification failed, the gate throws before a single script executes.
2. **The verdict is always reconciled.** `settleInstall(_install(), verifyLockfilePromise)` awaits the verification verdict first so it takes precedence and fails fast (even mid-install), then surfaces the install's result/error. This also covers paths that skip the build phase entirely (`ignoreScripts`, `lockfileOnly`, empty lockfile).

Verification's synchronous prologue (cache lookup, lockfile hash, candidate collection) still runs against the pristine lockfile before `_install()` mutates `ctx.wantedLockfile`, so the concurrent async fan-out reads a stable snapshot — no data race.

The verification verdict deliberately takes precedence over a concurrent install error: `pnpm add`'s full-resolution path can throw its own generic "resolution-policy violations produced but no handler wired" for the same underlying violation, and `settleInstall` makes sure the specific `minimumReleaseAge`/`trustPolicy` error is what surfaces.

## Change (pacquet / Rust)

Same optimization ported to `pacquet/crates/package-manager/`. `Install::run` builds the resolution verifiers up front but dispatches the verification fan-out per path:

- **Frozen materialization path:** verification runs concurrently with `CreateVirtualStore` (the fetch), settled with a `select!` so the verdict takes precedence: a rejected lockfile aborts the fetch in flight (fail-fast), while a fetch failure waits for the verdict and only surfaces once the lockfile is known trusted — an unrelated fetch error can't mask a rejected lockfile. The verdict is always reached before linking and `BuildModules`, so no dependency lifecycle script runs on an unverified lockfile.
- **Lockfile-only / up-to-date short-circuits and the fresh-resolve path:** keep an eager blocking gate — they have no fetch to overlap.

A verification failure surfaces as the same `InstallError::LockfileVerification` variant regardless of which path produced it.

On the pnpr client, a frozen restore now skips resolution entirely: tarball downloads start from the local lockfile at t=0 (filtered through one batched store-index existence probe, so a warm store prefetches nothing) while the server delivers only the trust verdict via the new `POST /v1/verify-lockfile` endpoint, concurrently with the fetch.

## Tests

- pnpm: `test/install/trustLockfile.ts` covers the rejection itself, the `trustLockfile` opt-out, and both script gates — a dependency `postinstall` never runs when verification fails, and the projects' own lifecycle hooks never run either, asserted on the `enableModulesDir: false` path with a *slow*-rejecting verifier (an instantly-rejecting one aborts the install before the hooks are attempted, which would hide a missing gate). Existing verification/lifecycle/`minimumReleaseAge` suites pass.
- pacquet: existing `frozen_lockfile_gate_rejects_under_huge_minimum_release_age` (unit) and `install_fails_under_huge_minimum_release_age` (CLI) assert the frozen install aborts with no virtual-store materialization on verification failure — proving the fail-fast settle cancels the fetch. New: `without_store_hits` + `StoreIndex::contains_many` unit tests pin the warm-store prefetch filter, and the frozen pnpr CLI test swaps the registry for a zero-expectation server before the restore, proving a warm-store frozen restore makes no registry requests.
- pnpr client/server: integration tests cover `/v1/verify-lockfile` accepting a clean lockfile, rejecting a policy violation, honoring `trustLockfile`, and forwarding the client's credential map (each verify call targets a fresh pnpr so no verdict/metadata cache can satisfy it without exercising the credential).
- clippy / `cargo doc -D warnings` / rustfmt / eslint clean; package-manager, lockfile-verification, store-dir, pnpr-client, and CLI pnpr-install suites all pass.

## Behavioral nuance

On a *rejected* lockfile, fetching/linking may now have partially populated the store/`node_modules` before the abort (previously nothing ran, since verification went first). The command still fails with the same error code and no lifecycle scripts run.
2026-06-12 09:45:46 +02:00
Zoltan Kochan
657d322b15 perf(network): schedule tarball downloads by estimated pipeline work (#12309)
## Summary

When the download connection pool saturates, freed slots are granted by a two-class scheduling policy instead of FIFO:

- **Latency class** (packument/metadata fetches, which gate resolution progress): served FIFO.
- **Throughput class** (tarball downloads): ranked by **estimated total pipeline work** — `unpackedSize + 3000 × fileCount` — so the most expensive download+extract jobs start first (longest-processing-time-first; a large archive that starts last runs alone at single-connection throughput while every other slot idles, see [pnpm/pnpm#12230](https://github.com/pnpm/pnpm/issues/12230)). The per-file term prices the fixed CAS-write/hash overhead, so a many-small-files package ranks as the long job it actually is.
- **Neither class can starve the other**: downloads are guaranteed a reserved half of the pool (strict metadata-first was measured to serialize cold installs — no tarball got a slot until resolution drained, costing the whole resolve/fetch overlap), and metadata wins beyond that reserve (a download backlog can't stall resolution). Both directions are work-conserving.

### How the size hints travel

- Local fresh installs read `dist.unpackedSize` / `dist.fileCount` off the resolver-fetched manifest (also fixes exact decompression-buffer preallocation on the prefetch path, previously hardcoded `None`).
- The pnpr `/v1/resolve` `package` frame carries both as optional `unpackedSize` / `fileCount` fields (omitted when the registry never published them; old clients and servers interoperate unchanged).
- pnpr frozen restores: the lockfile records no sizes, but the verification fan-out fetches each entry's metadata anyway — the npm verifier records both stats into an optional `ObservedDistStats` sink as a side product of the tarball-URL binding check, and the frozen fast path announces every verified tarball as a sized `package` frame before `done` (URLs derived by the same `tarball_url_and_integrity` the client materialization uses). Verdict-cache hits fetch no metadata and keep the bare `done` frame.
- pnpr's abbreviated metadata now **preserves** `unpackedSize`/`fileCount` instead of stripping them, since pacquet reads both.
- Resolve-time tarball fetches (tarball deps' manifests come from their archives) acquire in the latency class — they gate the resolver's walk.

### Benchmark tooling

- The integrated benchmark's latency proxy gained `--registry-slow-start`: per-connection TCP slow start (RFC 6928 initial window, doubling per delivered window toward the bandwidth cap), so scheduling effects that depend on per-connection ramp-up are measurable.
- Fixed a macOS bug where the proxy's accepted sockets inherited the listener's `O_NONBLOCK` and every proxied connection died on its first read — all shaped benchmark traffic silently failed before this.

## Measurements

Fixture: ~110 direct deps / 1308 packages (~90 MB wire), `isolated-linker.fresh-install.cold-cache.cold-store`, local mirror of real npm behind the shaped proxy (30 ms RTT, 80 Mbit/s per-connection cap, TCP slow start).

**Drift-controlled interleaved comparison** (4 alternating blocks x 4 runs each; sequential multi-target sessions on this machine showed up to +75% session-order drift, so block-paired ABAB is the only design we trust):

| target | mean +/- sd (n=16) |
| --- | --- |
| baseline FIFO | 14.36 s +/- 0.54 |
| this PR | **14.06 s +/- 0.70** |

The PR wins **all 4 paired blocks** (-0.18 s to -0.50 s, mean -0.30 s, ~2%). A scheduler ablation (reserve+FIFO, smallest-first, unpackedSize-only, work with K=3k and K=10k per file) ordered as the pipeline model predicts, but the per-variant deltas sit inside the session-drift noise, so only the FIFO-vs-full-design pairing is claimed. K in [3000, 10000] is indistinguishable.

**The starvation fix is the load-bearing piece, established mechanistically rather than by wall clock:** with strict metadata-first priority (an intermediate design), cold-install event timelines showed 4-7 s windows at install start with zero tarball activity - downloads never won a slot during the resolution burst, serializing the resolve and fetch phases. The reserved share removes those gaps entirely and the worst observed cold-install runs with it are within ~1 s of the median, where unreserved variants showed multi-second stragglers.

Real-registry A/B (15 randomized cold-install pairs against npmjs) is noise-bound on a saturated ~100 Mbit link (+/-3 s registry variance), median -0.17 s in this PR's favor - consistent with "never slower."
2026-06-11 02:58:36 +02:00
Khải
ac367fce91 chore(rust/clippy): pedantic, nursery, and some (#12209)
* chore: enable clippy::pedantic lint group for pacquet workspace

* style(pacquet): comply with clippy::pedantic

Apply clippy's machine-applicable pedantic fixes across the workspace
(inlined format args, removed needless borrows/closures, added
must_use, etc.), fix a few doc-comment backtick nits, and drop
pointless #[inline(always)] on trivial accessors.

Opt specific pedantic lints back out in [workspace.lints.clippy] with
documented justifications, grouped into false positives, library-API
hygiene that doesn't fit an internal CLI, suggestions that conflict
with the cardinal rule of porting pnpm 1:1, and opinionated style.

* style: taplo-format Cargo.toml lint table

* style(pnpr): comply with clippy::pedantic in merged auth backend code

Re-apply pedantic compliance to the networked-SQLite auth backend that
landed on main (#12186, #12199/#12206): doc-comment backticks, #[must_use]
on constructors and status_code, i64::from over `as`, map_or, and a
method-reference closure.

* docs(clippy): trim and inline the pedantic allow-list comments

* docs(clippy): note perfectionist supersedes many_single_char_names

* docs(clippy): note pnpm-mirroring rationale on structure/naming lints

* docs(clippy): mark unused_async as deferred pending audit

* style: enable clippy::match_wildcard_for_single_variants

* refactor: enable clippy::unused_self

Convert two self-less private methods (overrides pick_most_specific,
tarball head_only_result) to associated functions.

* refactor: enable clippy::ref_option

Widen engine_json to Option<&str>; #[expect] the two serde
serialize_with helpers, which serde must call as f(&field, ser).

* perf: enable clippy::trivially_copy_pass_by_ref

Pass the 1-byte Copy types NodeLinker and FilterWorkspaceProjectsOptions
by value; #[expect] the serde skip_serializing_if helper is_false.

* perf: enable clippy::assigning_clones

Use clone_from for seven field assignments to reuse allocations.

* style: enable clippy::manual_let_else

Convert 27 match/if-let guards to let-else; preserve the non-UTF-8
skip rationale comment in the directory walker.

* style: enable clippy::default_trait_access

Name the concrete type on Default::default() call sites; #[expect] two
struct-literal test fixtures where naming each field type would force
~20 imports.

* refactor: enable clippy::format_push_string

Replace push_str(&format!(...)) with write!/writeln! into the target
String (local 'use std::fmt::Write as _'); writeln! preserves the
exact LF/CRLF shell-shim output.

* refactor: enable clippy::needless_pass_by_value

Take by reference where the argument is only read (incl. dropping
some redundant clones in resolve_peers' recursion). Where converting
would cascade badly, #[expect] with a reason: functions that
destructure/consume the arg (build_resolve_result, PrefetchingResolver,
S3Store::new), the by-value `impl IntoIterator + Clone` in
build_direct_deps_by_importer, and the serde/test helpers whose owned
fixtures keep call sites clean.

* fix(perfectionist): satisfy dylint after format_push_string changes

Add trailing commas to the multi-line writeln! shell-shim templates
(macro_trailing_comma) and merge the new `fmt::Write as _` imports into
each file's existing `use std::{...}` block (import_granularity).

* docs(clippy): explain missing_errors_doc suppression; mark missing_panics_doc deferred

* fix(perfectionist): collapse fmt::{self, Write as _} in work_env imports

The format_push_string Write import landed as a sibling fmt:: path next
to the existing fmt import; merge them so import_granularity passes.

* style: enable clippy::return_self_not_must_use

Add #[must_use] to the WorkspaceTreeCtx builder methods, matching the
#[must_use] already on the parallel TreeCtx builders.

* perf: enable clippy::large_stack_arrays

Heap-allocate the 64 KiB read buffer in verify_file_integrity with a Vec
instead of placing it on the stack.

* chore(clippy): enable clippy::nursery group

Enable the nursery lint group on the pacquet/pnpr workspace and bring the
code into compliance.

Fixed in code:
- iter_on_single_items: [x].into_iter()/.iter() -> std::iter::once
- equatable_if_let: pattern match -> equality check (the install_accelerator
  rewrite wraps in a multi-line matches!, which gets a trailing comma for
  perfectionist::macro_trailing_comma)
- needless_pass_by_ref_mut: load_pending_row/apply_write_msg take &StoreIndex

Opted back out in Cargo.toml, each with a documented justification: use_self,
too_long_first_doc_paragraph, missing_const_for_fn, option_if_let_else,
significant_drop_tightening, redundant_pub_crate, derive_partial_eq_without_eq,
branches_sharing_code, useless_let_if_seq, single_option_map, iter_with_drain,
literal_string_with_formatting_args, collection_is_never_read.

Dropped the now-redundant individual nursery warns (needless_collect,
or_fun_call, redundant_clone) the group now covers, plus the default-on
unnecessary_lazy_evaluations. Kept clone_on_ref_ptr and if_then_some_else_none
(restriction lints not enabled by any group).

* style: bring merged main code into clippy pedantic compliance

The 17 commits merged from main predate this branch's pedantic/nursery
lint config, so their new code tripped pedantic lints. Apply the
machine-applicable fixes (uninlined_format_args, if_not_else,
elidable_lifetime_names, must_use_candidate, single_match_else,
map_unwrap_or, default_trait_access, assigning_clones, doc_markdown, ...)
and re-add the documented #[expect(needless_pass_by_value)] on
S3Store::new that this branch had carried on the now-replaced file.

* style: bring merged main code into clippy pedantic compliance

The 28 commits merged from main predate this branch's lint config, so
their new code tripped pedantic lints. Apply the machine-applicable fixes
(uninlined_format_args, manual_let_else, needless_raw_string_hashes,
redundant_closure_for_method_calls, map_unwrap_or, elidable_lifetime_names,
doc_markdown, ...) plus a few by hand:
- derive Copy on LinkSlotsParallel (all fields are Copy/refs) to clear
  needless_pass_by_value without a signature change
- deduplicate_all takes &[Vec<DepPath>] (it only borrows the duplicates)
- pick_most_specific becomes an associated fn (it never used self)
- default_trait_access -> concrete types; assigning_clones -> clone_from;
  format_push_string -> write!
- #[expect] with reasons where a fix would churn main's feature code:
  needless_pass_by_value on the recursive resolve_node and a test helper,
  and float_cmp on two deterministic-fixture assertions

* style: enable clippy::allow_attributes and allow_attributes_without_reason

Both are restriction lints (not implied by any group), enabled alongside
the existing clone_on_ref_ptr / if_then_some_else_none. Convert every
#[allow(...)] (including one nested in cfg_attr) to #[expect(...)]; all
already carried a reason, so allow_attributes_without_reason is satisfied.

Drop two now-redundant suppressions surfaced by the conversion: a
duplicated #[expect(too_many_arguments)] on fetch_and_extract_zip_once
(a prior merge left both an allow and an expect), and the
#[expect(dead_code)] on MissingPeerInfo's fields (the #[derive(Debug,
Clone)] already reads them, so dead_code never fired).

clone_on_ref_ptr was already enabled. mod_module_files is intentionally
NOT enabled: it mandates mod.rs, the opposite of the flat module.rs
pattern this project requires (CODE_STYLE_GUIDE.md, enforced by
perfectionist::flat_module_pattern).

* style: enable clippy::mod_module_files to enforce the flat module layout

mod_module_files bans mod.rs files, enforcing the flat module.rs pattern
this project already uses (0 mod.rs in the tree, so no violations). Update
CODE_STYLE_GUIDE.md to cite it as the enforcer; perfectionist's
flat_module_pattern is being retired in favor of this Clippy rule.

* fix(perfectionist): trailing comma on wrapped assert_eq! in workspace_yaml tests

The default_trait_access fix lengthened the assert_eq! so fmt wrapped it
to multi-line, which perfectionist::macro_trailing_comma requires to end
with a trailing comma.

* fix(fs): use cfg_attr expect instead of allow for Windows-unused mode args

With clippy::allow_attributes enabled, the #[cfg_attr(windows, allow(unused))]
on make_file_executable and the ensure_file/write_atomic mode params fails
Windows CI. Switch to #[cfg_attr(windows, expect(unused, reason = ...))];
on Windows the lint fires (Unix mode unused there) so the expectation is
fulfilled, and the attribute stays inert on Unix.

* fix(fs): drop the Windows unused suppression on ensure_file's mode arg

ensure_file forwards mode to verify_or_rewrite unconditionally, so it is
used on Windows too; the #[cfg_attr(windows, expect(unused))] was therefore
unfulfilled and failed Windows CI under -D warnings. write_atomic and
make_file_executable keep their expect — they use mode/file only under
#[cfg(unix)], so the lint fires (and the expectation holds) on Windows.

* chore(git): revert "fix(fs): drop the Windows unused suppression on ensure_file's mode arg"

This reverts commit 1d617c3e1f.

* chore(git): revert "fix(fs): use cfg_attr expect instead of allow for Windows-unused mode args"

This reverts commit 155e4a3dde.

* chore(git): revert "style: enable clippy::allow_attributes and allow_attributes_without_reason"

This reverts commit a47d7926f2.

* style: bring merged main code into clippy compliance + fix merge mismatch

- Add & at the two run_postinstall_hooks / run_project_lifecycle_scripts
  call sites: this branch widened lifecycle.rs to take &RunPostinstallHooks,
  but main's by-value call sites came in via the conflict resolution.
- pedantic fixes on main's new code: must_use_candidate, unnested_or_patterns,
  manual_let_else, default_trait_access, iter_on_single_items, and
  trivially_copy_pass_by_ref (map_node_linker takes NodeLinker by value).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 00:43:22 +02:00
Zoltan Kochan
5aed1200ea feat: add musl binaries for pacquet and pnpr (#12316)
Summary:
- add Linux musl binary package selection to the pacquet and pnpr npm shims
- generate linux-x64-musl and linux-arm64-musl native npm packages with libc metadata
- build musl Rust release targets for both pacquet and pnpr
- update package docs and cspell entries for the touched workflow files
2026-06-10 18:10:36 +02:00
Zoltan Kochan
b7195db5c8 chore(release): 11.5.3 (#12305) 2026-06-10 12:40:29 +02:00
Zoltan Kochan
8c9edf29c6 feat(lockfile): emit patchedDependencies block in pacquet lockfile (#12281)
* feat(lockfile): emit patchedDependencies block in pacquet lockfile

pacquet resolved and hashed patches for the depPath `(patch_hash=...)`
suffix and at build time, but never wrote the top-level
`patchedDependencies:` block into `pnpm-lock.yaml`, so a
`pacquet install --lockfile-only` diverged from pnpm (issue item 6 of
pnpm/pnpm#12266).

Add a `patched_dependencies` field to the `Lockfile` struct in its
`sortLockfileKeys` slot (between `pnpmfileChecksum` and `importers`),
populated via a new `Config::patched_dependency_hashes()` that ports
pnpm's `calcPatchHashes(opts.patchedDependencies)`: resolve each patch
path against the workspace dir and hash it, keeping the user's verbatim
keys so a bare `foo` and `foo@*` stay separate lockfile keys rather than
collapsing into one group bucket. The hashes are computed once per
install and threaded through `GraphToLockfileOptions`; the current
lockfile (`lock.yaml`) carries them through.

* feat(lockfile): check patchedDependencies drift in frozen freshness gate

Now that the lockfile records `patchedDependencies` hashes, the
frozen-lockfile freshness gate must reject an install when those hashes
drift — otherwise editing a patch file would not invalidate the
lockfile even though the patch hash participates in `(patch_hash=...)`
depPath identity.

Port pnpm's `getOutdatedLockfileSetting` patchedDependencies check:
`check_lockfile_settings` now takes the current install's
`patched_dependency_hashes()` and compares it (order-insensitively)
against `lockfile.patched_dependencies`, surfacing a new
`StalenessReason::PatchedDependenciesChanged` between the
`ignoredOptionalDependencies` and settings checks, matching upstream's
order. The pnpr fast-path bails when patches are configured.

Addresses review feedback on the patchedDependencies-block PR.

* feat(package-manager): detect in-place patch edits in the repeat-install fast path

The optimistic repeat-install fast path skipped the whole pipeline
before `check_lockfile_freshness` ran, and its workspace-state
comparison only checks the `patchedDependencies` key→path map. A patch
file edited in place (same config entry, new contents) therefore slipped
through: `node_modules` and the lockfile's recorded patch hash could stay
stale behind an "Already up to date".

Port the patch-file branch of pnpm's `patchesOrHooksAreModified`: a
configured patch whose mtime is newer than the workspace state's
`lastValidatedTimestamp` invalidates the fast path, checked before the
manifest-mtime exit so the patch reason wins. The pnpmfile branch and
the `assertWantedLockfileUpToDate` re-verification remain unported.

Addresses review feedback on the patchedDependencies-block PR.
2026-06-09 12:01:42 +02:00
Juan Picado
6f11872579 feat(pnpr): revalidate stale packuments with conditional GET to upstream (#12239)
* feat(pnpr): revalidate stale packuments with conditional GET to upstream

When a cached upstream packument goes stale past its TTL, pnpr now
revalidates it with a conditional GET instead of unconditionally
re-downloading the body. The upstream's ETag / Last-Modified are stored
in a sidecar (.package.json.meta) next to the cached packument and
replayed as If-None-Match / If-Modified-Since on the next refresh. A
304 Not Modified serves the cached copy and bumps its mtime so the entry
is fresh again, saving the (often large) packument download — matching
verdaccio's uplink revalidation.

The per-request access log gains a `cache=` field
(hit / revalidated / miss / stale / hosted) so operators can see how each
packument read was served against the proxy cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pnpr): cover packument cache storage and address review feedback

- Add storage-layer unit tests for the conditional-GET cache: validator
  sidecar round-trip, sidecar removal when validators are empty, malformed
  sidecar degrading to empty validators, and TTL freshness.
- Only treat an upstream 304 as NotModified when a conditional header was
  actually sent; an unconditional 304 (e.g. cold cache) now falls through
  to a status error instead of being mistaken for a usable cached copy.
- Move packument bytes out on the cache-hit path instead of cloning the
  (potentially multi-MB) document on every request.
- Un-ignore pnpr's `src/storage/` source dir: the monorepo-wide `storage`
  gitignore rule (for Verdaccio runtime data) was swallowing the new test
  file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pnpr): cover packument cache storage and address review feedback

- Add storage-layer unit tests for the conditional-GET cache: validator
  sidecar round-trip, sidecar removal when validators are empty, malformed
  sidecar degrading to empty validators, and TTL freshness.
- Only treat an upstream 304 as NotModified when a conditional header was
  actually sent; an unconditional 304 (e.g. cold cache) now falls through
  to a status error instead of being mistaken for a usable cached copy.
- Move packument bytes out on the cache-hit path instead of cloning the
  (potentially multi-MB) document on every request.
- Un-ignore pnpr's `src/storage/` source dir: the monorepo-wide `storage`
  gitignore rule (for Verdaccio runtime data) was swallowing the new test
  file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(pnpr): defer stale packument body read; add `orphaned` cache status

- Read a stale cached packument's body lazily: `read_cached_packument_entry`
  now returns `Fresh(bytes)` or `Stale(validators)`, so the common
  stale->200 refresh no longer reads and allocates the (potentially
  multi-MB) old body. It's pulled on demand only on the 304 / upstream-error
  paths that actually need it.
- Record `cache=orphaned` (not `hit`) when serving a leftover mirror with no
  upstream left to revalidate against, since that path serves regardless of
  the TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(pnpr): defer stale packument body read; add `orphaned` cache status

- Read a stale cached packument's body lazily: `read_cached_packument_entry`
  now returns `Fresh(bytes)` or `Stale(validators)`, so the common
  stale->200 refresh no longer reads and allocates the (potentially
  multi-MB) old body. It's pulled on demand only on the 304 / upstream-error
  paths that actually need it.
- Record `cache=orphaned` (not `hit`) when serving a leftover mirror with no
  upstream left to revalidate against, since that path serves regardless of
  the TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(pnpr): defer stale packument body read; add `orphaned` cache status

- Read a stale cached packument's body lazily: `read_cached_packument_entry`
  now returns `Fresh(bytes)` or `Stale(validators)`, so the common
  stale->200 refresh no longer reads and allocates the (potentially
  multi-MB) old body. It's pulled on demand only on the 304 / upstream-error
  paths that actually need it.
- Record `cache=orphaned` (not `hit`) when serving a leftover mirror with no
  upstream left to revalidate against, since that path serves regardless of
  the TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pnpr): cover packument cache storage and address review feedback

- Add storage-layer unit tests for the conditional-GET cache: validator
  sidecar round-trip, sidecar removal when validators are empty, malformed
  sidecar degrading to empty validators, and TTL freshness.
- Only treat an upstream 304 as NotModified when a conditional header was
  actually sent; an unconditional 304 (e.g. cold cache) now falls through
  to a status error instead of being mistaken for a usable cached copy.
- Move packument bytes out on the cache-hit path instead of cloning the
  (potentially multi-MB) document on every request.
- Un-ignore pnpr's `src/storage/` source dir: the monorepo-wide `storage`
  gitignore rule (for Verdaccio runtime data) was swallowing the new test
  file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:38:11 +02:00
Zoltan Kochan
de32f83a67 fix(pnpr): parse /v1/resolve NDJSON stream in the TypeScript client (#12246)
* fix(pnpr): parse /v1/resolve NDJSON stream in the TypeScript client

The pnpr server streams the /v1/resolve response as application/x-ndjson
(one package frame per resolved tarball, then a terminal done/error/
violations frame), but the TypeScript client still parsed the whole body
as a single JSON object, failing with "Unexpected non-whitespace
character after JSON". Parse the NDJSON frames and act on the terminal
frame instead.

Closes #12234 follow-up.

* fix(pnpr): reject unknown /v1/resolve frame types in the client

Fail fast with a protocol error when the NDJSON stream carries a frame
whose type is neither package nor a known terminal frame, instead of
returning it and surfacing a confusing lockfile error downstream.
2026-06-06 19:55:06 +02:00
Zoltan Kochan
c199198e94 perf(pnpr): stream /v1/resolve, and fix the integrated benchmark to actually exercise pnpr (#12237)
Closes #12234.

This PR has two parts. The headline turned out to be the **benchmark**, not the feature.

## Part 1 — `/v1/resolve` streaming (the #12234 feature)

`POST /v1/resolve` now streams **NDJSON** instead of buffering the whole lockfile: one `package` frame per resolved tarball as the server's tree walk yields it, then a terminal `done` (full lockfile + stats), `error`, or `violations` frame. The client fetches each tarball as its frame arrives, overlapping the server's resolution — matching the native `PrefetchingResolver` shape.

- Server: new `ResolutionObserver`/`ObservingResolver` in `package-manager`, threaded through `Install`; `handle_resolve` runs the resolve in a detached task whose observer pushes frames into the response channel; `application/x-ndjson` is excluded from the gzip layer so frames flush incrementally.
- Client: `resolve_streaming(opts, on_package)`; `install_via_pnpr` drives a new `TarballPrefetcher` that warms the shared mem cache as frames arrive.
- Breaking change within protocol v1 (no version bump — experimental pnpr allows it).

**Honest caveat:** streaming only helps when the *server's* resolve is slow (cold/distant server). Against a warm server it's inert — see the results below (`pnpr@HEAD` ≈ `pnpr@main` everywhere). Whether to keep this commit or defer it is an open question; the benchmark fixes below stand on their own.

## Part 2 — make the integrated benchmark actually measure pnpr

While validating Part 1, the benchmark turned out not to be exercising pnpr **at all**, plus it was serving the registry far faster than reality. Fixes:

- **`pnpr@<rev>` targets never routed through pnpr.** `.pnpr-env` exported a bare `PNPR_SERVER`, but pacquet reads config env vars only under the `PNPM_CONFIG_*` prefix, so `config.pnpr_server` was always `None` and every pnpr row was a silent duplicate of its direct row. Fixed the env var name; added a post-run guard that fails the benchmark if a pnpr target's `pnpr-storage` is empty (proof it never served a resolve).
- **Emulate a real registry link for every client.** The latency proxy modeled RTT but not throughput, and fronted only direct targets. Generalized it to a `LinkProfile` (one-way delay + per-direction bandwidth cap), added `--registry-bandwidth-mbps`, and routed *all* registry traffic through it (direct installs, the pnpr server's resolve, the pnpr client's fetches) so the registry-mock is uniformly as remote as real npm. CI runs it at 50 ms + 200 Mbit/s (≈ the measured public-npm peak).
- **Make "cold cache" cold for resolution.** Forced `cacheDir` bench-local and wipe it in cold-cache scenarios, so a direct install actually pays the packument-fetch waterfall (previously the global metadata mirror survived every wipe).
- **New scenario** `fresh-install.cold-cache.hot-store` that isolates resolution (cold metadata, hot store → no download to mask it).

## Results (Linux CI, after the fixes)

| Scenario | pacquet@HEAD | pnpr@HEAD | pnpr speedup |
|---|---:|---:|---:|
| fresh-install · cold cache · **hot store** | 5.06 s | 0.69 s | **7.4×** |
| fresh-install · cold cache · cold store | 5.36 s | 2.03 s | **2.65×** |
| fresh-install · hot cache · hot store | 1.46 s | 0.69 s | **2.1×** |
| fresh-restore (frozen) · cold cache · cold store | 10.08 s | 5.13 s | **2.0×** |
| fresh-restore (frozen) · hot cache · hot store | 0.71 s | 0.80 s | 0.89× (slower) |

pnpr offloads the client's resolution (and, on the frozen path, lockfile verification) to its warm server: 2–7× faster wherever the client would otherwise pay that cost. The lone regression is the fully-warm frozen install, where there's nothing to offload and pnpr's one round trip is pure overhead. `pnpr@HEAD` vs `pnpr@main` is flat throughout — i.e. the streaming commit (Part 1) adds ~nothing against a warm server, while the base pnpr win (offload to a warm server) is large.
2026-06-06 15:25:08 +02:00
Zoltan Kochan
089484aca8 perf(pnpr): resolve server-side and fetch tarballs directly (#12232)
## Summary

Reworks pnpr from an install/file accelerator into a resolve-only accelerator:

- `POST /v1/resolve` resolves against the client-supplied registries and returns a gzipped JSON lockfile response
- pacquet/pnpm clients then fetch tarballs normally from registries with their own credentials and existing parallel fetch/integrity paths
- pnpr no longer serves package file bytes or store-index rows, so the server-side file diff, file-frame response, grant table, and public-package byte-gating code are removed

The follow-up resolution fast paths are included on the new measured path:

- repeated public no-lockfile resolves use a bounded in-memory TTL cache
- fresh frozen input lockfiles skip the server-side lockfile-only pacquet resolve after verification proves the lockfile is usable
- input lockfile verification and the verdict cache are preserved

## Benchmark

Integrated benchmark on Linux shows small improvements in all pnpr rows, with the clearest movement in hot restore. This should be treated as an incremental win rather than a large install-speed change.

| Scenario | `pnpr@HEAD` | `pnpr@main` | Change |
| --- | ---: | ---: | ---: |
| fresh restore, cold cache + cold store | `1.677 s ± 0.090` | `1.686 s ± 0.070` | ~0.6% faster |
| fresh restore, hot cache + hot store | `492.5 ms ± 18.1` | `521.9 ms ± 33.4` | ~5.6% faster |
| fresh install, cold cache + cold store | `1.997 s ± 0.025` | `2.003 s ± 0.038` | ~0.3% faster |
| fresh install, hot cache + hot store | `1.211 s ± 0.024` | `1.236 s ± 0.038` | ~2.0% faster |

## Trade-off

Going registry-direct means pnpr no longer gates tarball bytes itself. Private package access is enforced by the upstream registry when the client fetches tarballs. Resolution policy still runs server-side: lockfile verification, release-age policy, trust policy, and resolved package selection continue to happen before the client fetches bytes.
2026-06-06 02:16:33 +02:00
Juan Picado
ae212c8e1b feat(pnpr): forward uplink auth token and custom headers to upstreams (#12186)
* feat(pnpr): forward uplink auth token and custom headers to upstreams

pnpr now attaches a per-uplink `Authorization` header (derived from the
verdaccio-shaped `auth:` block) plus any operator-supplied custom
`headers:` on every packument and tarball request it makes to an
upstream registry. This unblocks proxying private upstreams
(CodeArtifact, GitHub Packages, authed npm Enterprise, private
verdaccio) that previously returned 401/403.

`auth` supports `type: bearer | basic`, an inline `token`, and
verdaccio's `token_env` (`true` -> `NPM_TOKEN`, or a named var); an
inline `token` takes priority. A custom `headers.Authorization`
overrides the auth-derived one, matching verdaccio's merge order.
Tokens/headers resolve once at config load through the existing
`EnvVar` seam, so a missing token or invalid header value fails fast as
an `InvalidConfig` error rather than a silent unauthenticated request.

Implements the "Forward auth.token / custom headers to uplinks" item
under Uplinks & caching in the pnpr-verdaccio-parity tracking issue.

Ref: https://github.com/pnpm/pnpm/issues/11973

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pnpr): cover uplink auth/header resolution error branches

Add tests for the previously-uncovered branches in resolve_uplink and
from_yaml_str flagged by Codecov on the uplink-auth patch:

- token_env false resolves no token (config error)
- an auth token that is not a valid header value (config error)
- an invalid custom header name (config error)
- an invalid custom header value (config error)
- an unresolved env-var reference is tolerated, not an error

Brings config.rs from 92.05% to 96.69% line coverage; the patch's nine
missing lines are now fully covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pnpr): satisfy perfectionist lints and rustdoc on uplink auth code

CI's stricter dylint/perfectionist pass and `cargo doc` (which plain
clippy does not run) flagged the uplink-auth changes:

- collapse the split `reqwest::` and `crate::` imports to one `use` per
  crate root (perfectionist::import-granularity = crate)
- add the trailing comma to the multi-line `format!` in resolve_uplink
  (perfectionist::macro-trailing-comma)
- drop the intra-doc links from the public `UplinkConfig` docs to the
  private `UplinkFile`/`resolve_uplink` items, which broke
  `cargo doc --document-private-items` under -D warnings

No behavior change. Verified locally with `cargo dylint --all`,
`cargo doc --document-private-items`, clippy, and the config tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pnpr): redact uplink header values in Debug output

UplinkConfig and Upstream both hold a resolved HeaderMap that can carry
an Authorization credential (or a secret in a custom header). The derived
Debug printed those values verbatim, so a debug log or span could leak
them. Replace the derives with hand-written impls that route the map
through a RedactedHeaders wrapper, which lists header names with every
value rendered as <redacted>.

* fix: reject empty uplink auth tokens

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-05 09:54:09 +02:00
Zoltan Kochan
70554b8677 feat(pnpr): config-selectable networked-SQLite auth backend (#12199 phase 3) (#12206)
## What

Implements the **auth half** of [#12199](https://github.com/pnpm/pnpm/issues/12199) (phase 3) — making pnpr's remaining per-instance state pluggable so the registry can run as stateless, horizontally-scaled replicas.

### Auth records behind config-selected backends
Users + tokens now sit behind narrow async `UserBackend` / `TokenBackend` traits, built once at startup into `Arc<dyn …>` handles (the same build-once pattern #12198 used for the hosted store). Three implementations:

- **Local** (default) — today's htpasswd file + SQLite token DB, or in-memory when no file is configured. Unchanged behavior.
- **Networked SQLite (libsql / Turso)** — `LibsqlAuth` stores **both** records in one shared database, so several stateless replicas observe a consistent set of users and tokens. The `tokens` table DDL is shared verbatim with the local backend (a DB can migrate between them); users — which the local backend keeps in htpasswd — move into a `users` table.

Selected via a new top-level YAML block:

```yaml
backend:
  libsql:
    url: ${PNPR_LIBSQL_URL}
    authToken: ${PNPR_LIBSQL_TOKEN}
    # optional embedded replica for local-fast hot-path reads:
    replicaPath: ./auth-replica.db
    syncIntervalSecs: 60
```

When the block is absent, auth stays on local disk exactly as before.

### Embedded-replica read acceleration
Token lookups are on the request hot path, so against a remote primary every read would be a network round-trip. With `replicaPath` set, `LibsqlAuth` builds a libsql **embedded replica**: reads hit a local file that libsql keeps current in the background; writes go to the primary. `syncIntervalSecs` is the freshness knob that bounds token-revocation lag.

### Async access path
`identify` / `enforce_access` are now async (a networked lookup is async). `enforce_access` is split into an async `resolve_identity` + a sync `authorize`, so the search endpoint resolves the caller once and authorizes each candidate synchronously (no async-in-`retain`).

### Concurrent-publish guard (cross-cutting follow-up from the issue)
Closes the same-instance lost-update window in the three read-modify-write packument flows (publish, dist-tag change, partial-unpublish): a striped per-package lock serializes same-package writers on one instance while letting different packages proceed in parallel. The **cross-replica** half (S3 `If-Match` / ETag CAS) is documented in-code as the remaining piece — the issue files it under "fix when we get there," and it belongs with the multi-writer S3 publish work, not this auth branch.

## Tests
All green — `cargo test -p pnpr`:
- **176 lib unit tests** incl. new `LibsqlAuth` tests (run against an in-memory libsql DB — same driver + SQL, no server) and `backend.libsql` config-parsing tests (incl. the replica options).
- New `concurrent_publishes_of_distinct_versions_all_survive` integration test for the publish guard.
- Existing auth_persistence / auth_user_endpoints / auth_publish / server / s3_backend suites pass.
- Clean under `cargo fmt`, `clippy`, `RUSTDOCFLAGS=-D warnings cargo doc`, **Dylint perfectionist**, and `taplo`.

## Docs
`backend.libsql` (incl. embedded replica) documented in the bundled `config.yaml` and the `pnpr` npm README, mirroring how the S3 backend was documented in #12198.
2026-06-05 09:15:17 +02:00
Zoltan Kochan
4b4d38361c chore(release): 11.5.2 (#12207) 2026-06-05 08:27:41 +02:00
Zoltan Kochan
8e5e764037 feat(pnpr): store hosted packages in an S3-compatible object store (#12198)
## What

Lets pnpr store its **hosted** packages (the ones published to it, plus static-served content) in an **S3-compatible object store** instead of a local directory. Because the same code targets any S3-compatible endpoint, this also covers **Cloudflare R2**, MinIO, Backblaze B2, Wasabi, etc.

The local `tokio::fs` path remains the default — nothing changes unless you add the new `s3:` config block.

## Why

The hosted store is pnpr's source of truth: durable, must be backed up, and can't be regenerated. That's exactly what belongs in object storage:

- The provider handles durability/replication, so there's no single-node volume to back up.
- Multiple **stateless pnpr replicas** can share one hosted store.
- R2 is the S3 API, so a configurable `endpoint` gets it (and the other S3-compatibles) for free.

The disposable proxy cache and the install-accelerator SQLite stores deliberately **stay on local disk** — they're ephemeral, latency-sensitive, and streamed/locked in filesystem-shaped ways.

## How

- New `s3.rs` module: `S3Settings` (the YAML `s3:` block), a client builder (`object_store` crate; AWS-env credentials with explicit override, plus R2/MinIO/path-style/HTTP knobs), and an `S3Store` adapter (packument get/put, streaming tarball get, staged upload, delete, prefix-scoped list).
- `storage.rs`: a `HostedStore { Fs | S3 }` backend enum routes the hosted ops; the `cached` store stays fs-only. Publish stages the decoded+verified tarball to local scratch, then finalize either renames (fs) or uploads (S3). `open_tarball` now returns a streaming response body so S3 reads stream straight through.
- `config.rs`: parses `s3:` and builds the client once at config-load time (the only fallible step), so `Storage` construction stays infallible.
- `search.rs`: local search now lists package names through the storage abstraction, so it works against a bucket too.
- Documented (commented) in the bundled `config.yaml`.

### Example: Cloudflare R2

```yaml
storage: ./storage   # still backs the local proxy cache + upload staging
s3:
  bucket: my-pnpr-packages
  region: auto
  endpoint: https://<account-id>.r2.cloudflarestorage.com
  accessKeyId: ${PNPR_S3_ACCESS_KEY_ID}
  secretAccessKey: ${PNPR_S3_SECRET_ACCESS_KEY}
```
2026-06-04 22:44:53 +02:00
Zoltan Kochan
43ad0941a1 feat(pnpr): separate the proxied upstream cache from published packages (#12195)
## What

Splits pnpr's on-disk storage into two physically separate roots so the disposable proxy cache and the authoritative **hosted** packages no longer share a lifecycle.

Closes #12194.

### Before

Proxied upstream packuments/tarballs and locally-published packages were written to the same `<storage>/<pkg>/` tree through a single `Cache` abstraction, with no marker distinguishing them. Consequences:

- No safe way to clear the proxy cache — deleting a package dir removed hosted packages too.
- Hosted packages shared a lifecycle with disposable cache; a naive "clear the cache" could wipe the source of truth.
- Backups and upgrades had to treat the entire (reconstructible) mirror as precious data.

### After

| | `storage` (hosted) | `cache` (proxy) |
|---|---|---|
| Holds | packages this server hosts directly (published via its API) + static-served content | proxied upstream mirror + install-accelerator store |
| Durability | source of truth, never overwritten by an upstream refresh | safe to wipe anytime; self-heals on next request |
| Default | `./storage` | `<storage>/.pnpr-cache` |
| Override | `storage:` / `--storage` | `cache:` / `--cache` (point at a separate ephemeral volume) |

- A new `Storage` type wraps two `Store` roots — `hosted` (authoritative) and `cached` (disposable). Reads prefer the hosted store; a hosted/static packument is served as-is and never refreshed over.
- Publish, unpublish, packument PUTs and dist-tag changes write to the hosted store; upstream refreshes write only to the cache.
- Removal clears both stores — full unpublish *and* partial (single-tarball) unpublish — so a stale proxied copy can't resurface via the tarball-read fallback.
- The install-accelerator CAS + verdict DB move under the cache root.

### Naming

- Internally the two roots are the `hosted` / `cached` fields of the `Storage` type (`hosted`/`proxy` is the registry-server convention, e.g. Sonatype Nexus).
- The user-facing YAML keys stay `storage:` / `cache:` (nouns that name directories, the clearest register for a setting), which also keeps verdaccio-shaped configs working.

### Server / deployment

- Put `storage` on a durable, backed-up volume and `cache` on scratch/ephemeral disk (or just leave the default subdir).
- **Upgrades retain hosted packages trivially**: point the new server at the same `storage`; the cache can start cold.
- **DR**: back up only `storage`.
2026-06-04 19:43:32 +02:00
Zoltan Kochan
5192edf40e feat(pnpr): forward credentials and add per-user access grants for external private registries (#12184) (#12189)
Closes #12184 (part 2).

#12181 shipped the per-caller access gate on `POST /v1/install`, which authorizes every served package against pnpr's own `packages:` policy — the complete answer **while pnpr fetches anonymously**. This PR adds the remaining piece: forwarding the caller's per-registry credentials so the accelerator can resolve/fetch **external private** content as the caller, and gating that content per user against the registry that actually owns it.

## Credential forwarding (issue steps 1–2)

- **Wire:** `POST /v1/install` gains an `authHeaders` body map (`{ "//host/path/": "Bearer …" }`, the shape `AuthHeaders::from_map` consumes / `getAuthHeadersFromCreds` produces) plus an HTTP `Authorization` header. The body map carries the *upstream* registry tokens; the header identifies the caller to pnpr's own gate and keys the grant table.
- **pacquet plumbing:** a request-scoped `Arc<AuthHeaders>` is threaded via a new `Install.auth_override` field and an `auth_override` param on `build_resolution_verifiers`, so resolution/verification run as the caller **without** baking per-user auth into the interned `&'static Config` (which would leak one config per user).
- **Server:** `handle_install` builds the per-request `AuthHeaders` and threads it through resolve, verify, and `fetch_uncached` (which now returns the freshly-fetched set).
- **Clients:** pacquet `pnpr-client` and `@pnpm/pnpr.client` send `registry` / `namedRegistries` / `authHeaders` + `Authorization`; the TS path sources them from the caller's registry credentials via `@pnpm/network.auth-header` (`getAuthHeadersFromCreds` is newly re-exported). `@pnpm/worker` is unchanged — downloads happen server-side.
- **Credential scope:** both clients forward the caller's *full* credential map, not a subset scoped to the declared registries. The registries a dependency graph touches aren't knowable up front — a transitive package can be scope-routed to another registry or pinned to a tarball URL on a host that's in `.npmrc` but isn't a declared registry — so pnpr attaches the right token per fetched URL exactly as a local install does. These are package-fetch credentials going to the very service the caller configured to fetch its packages.

## Per-user grant table (issue steps 3–4)

Externally-resolved private content carries no pnpr policy, so the store's possession of the bytes must not authorize a user the upstream never cleared. A served package is dispatched by **whether a forwarded credential was used to fetch it**:

- **No forwarded cred → pnpr-as-authority:** the existing local `packages:` policy check, unchanged.
- **Forwarded cred → upstream-as-authority:** gated against a persistent `(user, name@version)` grant table (SQLite, modeled on `VerdictCache`). Freshly fetched this request ⇒ record + allow (the upstream just accepted the token). Cache hit with a standing grant ⇒ allow, no upstream trip. Cache hit, no grant ⇒ re-verify against the owning registry with the caller's credential — record on success; **clear-on-discovery** (purge the user's grants for the package) + deny on `401`/`403`. TTL is the `installAccelerator.grantTtl` config knob (default: permanent).

## Public vs private (no per-user gating for public packages)

A forwarded credential matching a registry doesn't mean a package is *private* — in a mixed proxy (one registry serving a company's private packages **and** public ones), the token matches everything, and gating public content per user would cost a grant row and a re-verify round trip per user for bytes anyone may read. So before the per-user path, a not-yet-classified cache hit is probed **anonymously**: a `2xx` classifies the package public in a global set (no user pays for it again, no grant, no further round trip); a `401`/`403` means it's genuinely private and falls through to the grant / re-verify path above. Public packages thus cost **one anonymous probe across the whole fleet**, not one per user.

## Tests

- pnpr: grant-table + public-set mechanics, regime dispatch, the upstream-authorization paths (fresh-fetch, granted cache hit, private re-verify-and-record, denied-clears-grants, public-classified-once-then-free), and forwarded-cred-routes-around-local-policy.
- pacquet `pnpr-client`: a test asserting `authHeaders` + `Authorization` travel on the wire.
- Full suites green: `pnpr` (237), `pacquet-package-manager` (389), `pacquet-pnpr-client` (12), `pacquet-network`/`config` (325); clippy `-D warnings`, `cargo fmt`, rustdoc `-D warnings --document-private-items`, `typos`, and the TS compile all clean.

## Scoped follow-ups (not in this PR)

- Clear-on-discovery fires at the re-verify hook only. A `401`/`403` during the cold resolve aborts the request anyway (nothing is served); threading the offending package out of the deep resolve error to also clear stale grants for *future* requests needs structured auth errors.
- Per-scope external registries route via the default registry, since pacquet doesn't yet surface `@scope:registry` routing in `collect_packages`.
2026-06-04 18:45:56 +02:00
Zoltan Kochan
3b76b8eaed fix(pnpr): access-gate install-accelerator files and remove unauthenticated /v1/files (#12181)
* fix(pnpr): authorize served packages against pnpr's policy in /v1/install

A content-addressed digest in the install-accelerator store is shared
across packages and says nothing about access, so the store's possession
of a package's bytes is not a capability to receive them. `/v1/install`
served files for any package found in the store, including ones reached
only on the cache-hit / frozen-lockfile path where no access check
happened — letting a caller who knows a private package's digest pull
bytes the registry routes would 401 on.

Check every served package against pnpr's own `packages:` policy before
serving — the same decision `serve_packument` / `serve_tarball` make, in
process, with no network round trip (so a warm shared server keeps its
resolution advantage). `serve_install` resolves the caller's identity
from `Authorization`; `deny_unauthorized_packages` denies the install
(401 anonymous / 403 authenticated-but-outside-the-allowed-set) when any
served package is not readable by the caller.

This authorizes against pnpr's own surface, the authority for everything
the store can hold today (pnpr fetches anonymously, so cached content is
pnpr-hosted or publicly fetchable). When credential forwarding lands,
packages the client resolved from external registries under its own token
carry no pnpr policy and will need per-caller re-verification against the
owning registry (TTL-cached) — noted at the check and tracked in #12184.

The raw `/v1/files` endpoint is still unauthenticated; removing it (it is
superseded by the inline single-response path) is a follow-up (#12184)
that also ports the TS `@pnpm/pnpr.client` + worker off the two-trip path.

---
Written by an agent (Claude Code, claude-opus-4-8).

* fix(pnpr): remove the unauthenticated /v1/files endpoint

`POST /v1/files` served any CAFS file by digest with no authentication
and no package identity, so the access gate on `/v1/install` (which is
per package) couldn't cover it — it had to be removed, not gated. It was
already superseded by the single-response inline path (#12178).

* Server: `/v1/install` always answers with the inline gzipped body
  (lockfile + stats + store-index entries + the missing files' contents);
  the NDJSON two-trip path, the `/v1/files` route, `handle_files`, and the
  `FilesRequest`/`is_valid_sha512_hex` helpers are gone.
* TS client + worker: `@pnpm/pnpr.client` now does the one inline request
  and hands the file frames to `@pnpm/worker`'s `writeCafsFiles`, which
  writes them to the CAFS; the `fetchAndWriteCafsFiles` /v1/files fetcher
  is replaced. Error bodies are decompressed before being surfaced, since
  the server also gzips its JSON error responses (e.g. an access denial).

Verified end to end by `pnpm/test/install/pnpmRegistry.ts` (11 tests:
install / add / remove / workspace through a real pnpr server).

Closes the second half of the install-accelerator access work (#12184);
file-bearing responses are now both inline-only and access-gated.
2026-06-04 09:32:28 +02:00
Zoltan Kochan
f06ab5e201 perf(pnpr): collapse the install-accelerator cold path to one round trip (#12178)
Reduce the cost of a pnpr install-accelerator install against a remote
  server, where latency — not bandwidth — dominates.

  The cold path was three sequential round trips: a GET /-/pnpr handshake,
  a POST /v1/install that returned the lockfile + missing-file digests as
  NDJSON, and a POST /v1/files that fetched the file contents. At 100ms RTT
  most of an install is spent waiting on trips, not transferring data.

  - A new `inlineFiles` request flag makes POST /v1/install return a single
    gzipped body: a length-prefixed JSON header (lockfile, stats,
    store-index entries, or verification violations) followed by the
    missing files' contents as the same binary frames /v1/files serves.
    The pacquet client drops the handshake, sends the flag, and writes the
    inlined files straight to its CAFS — no follow-up fetch. The legacy
    NDJSON + /v1/files path is unchanged for clients that don't set it.
  - File-bearing responses now compress at gzip level 6 (was level 1),
    ~16% smaller payload at negligible CPU.

  Measured at 50ms one-way latency (warm server, 5-run average): a
  single-round-trip install drops from ~381ms to ~135ms (≈2.8x).

  Two further experiments from the issue were evaluated and not adopted:
  server-side fetch/transfer streaming (the public npm registry is
  CDN-backed and latency-bound, not bandwidth-throttled, so there is
  nothing to overlap — it measured slower under an artificial bandwidth
  cap) and bloom-filter integrity uploads (real but niche, and false
  positives would require a correctness-critical re-fetch fallback).

  Closes #12165
2026-06-03 23:36:28 +02:00
Zoltan Kochan
3492bb8f4a feat(pnpr): gzip-compress package metadata over the wire (#12170)
Neither side used gzip for package metadata: pacquet fetched packuments
uncompressed from every registry (unlike pnpm-TS, which gets gzip via
undici), and pnpr served them uncompressed. Packuments are the largest
payloads pulled during resolution and gzip ~5-10x, so this was a real
resolution-time cost and a divergence from how a CDN-fronted registry
behaves. Closes #12169.

Both halves are needed and land together:

- Client (pacquet): enable reqwest`s `gzip` feature and set `.gzip(true)`
  explicitly on the network client builder, so it sends
  `Accept-Encoding: gzip` and transparently decompresses. Tarballs are
  unaffected (served as `application/octet-stream` with no
  `Content-Encoding`, so reqwest leaves them alone and store-integrity
  verification is unchanged). It also transparently handles the install
  accelerator's already-gzipped `/v1/files` stream — the client's
  existing magic-byte check covers the now-auto-decompressed case.

- Server (pnpr): add a `tower-http` `CompressionLayer`, scoped to JSON
  via `NotForContentType` so it compresses packuments / version
  manifests / dist-tags / search but never re-gzips an already-compressed
  payload: tarballs (`application/octet-stream`), the file stream
  (`application/x-pnpm-install`), or the resolve NDJSON
  (`application/x-ndjson`). pnpr is commonly hit directly with no CDN or
  nginx in front, so the application is the only layer that can compress;
  where a proxy/CDN is present, the `Content-Encoding: gzip` is passed
  through (no double compression).

Tests assert a packument is gzipped when `Accept-Encoding: gzip` is sent,
served plain otherwise, and a tarball is never re-gzipped.
2026-06-03 17:11:21 +02:00
Zoltan Kochan
e1648a6ca0 perf(pnpr): coarsen packument time precision to shrink abbreviated responses (#12168)
pnpr serves abbreviated packuments uncompressed, so the verbatim `time`
map is pure wire cost. Drop precision the resolvers don't need: seconds
come off every entry, and entries older than a week lose the time-of-day
entirely (down to a bare date). The reserved `unpublished` object and any
non-RFC-3339 value pass through untouched.

Timestamps are rounded *up* (next minute / next day, leaving values
already on the boundary untouched), so a coarsened value is never earlier
than the real publish time. `minimumReleaseAge`, the abbreviated-modified
shortcut, and the trust checks can therefore only ever read a version as
newer than it is — the fail-safe direction; a too-new version is never
coarsened into looking mature.

Both reduced forms are accepted by pnpm's lenient `new Date(...)`. pacquet
parsed strict RFC 3339 only, so add a shared `parse_packument_timestamp`
in resolving-resolver-base that also accepts minute precision
(`2024-03-15T09:42Z`) and bare dates (`2024-03-15`, read as midnight UTC),
and route every existing publish-timestamp parse site through it.
2026-06-03 15:49:26 +02:00
Zoltan Kochan
6305e955c6 perf(pnpr): shrink the abbreviated packuments served to clients (#12163)
## What

When pnpr proxies a registry, trim the `application/vnd.npm.install-v1+json` packument down to only the fields the pnpm and pacquet resolvers actually read, so clients download, parse, and cache less metadata.

Only the abbreviated path (`Accept: application/vnd.npm.install-v1+json`) is affected; full-document clients are untouched.

### Dropped (never read during resolution)

- **top-level:** `readme`, `readmeFilename`, `_id`, `_rev` — `readme` is the dominant per-packument bloat (full README text) and npm's own abbreviated format never carried it.
- **per-version:** `funding`, `devDependencies`, `acceptDependencies`, `_hasShrinkwrap`. A dependency's `devDependencies` are never installed, so the resolver has no use for them.
- **per-version `dist["npm-signature"]`** — npm's deprecated PGP detached signature. npm stopped populating it years ago and nothing in pnpm or pacquet reads it.
- **per-version `dist.fileCount`** — read nowhere in pnpm or pacquet.
- **per-version `dist.unpackedSize`** — read only by `pnpm view`, which fetches the full metadata document (`fullMetadata: true`) that pnpr serves unstripped.
- **per-version `dist.shasum` when `dist.integrity` is present.** Both pnpm (`getIntegrity`) and pacquet prefer SRI `integrity` and only fall back to the legacy sha1 `shasum` when `integrity` is absent, so shipping both is a redundant hash on every version. `shasum` is **kept** when `integrity` is absent (pre-2017 publishes) so the `getIntegrity` fallback still has a hash.

### Deliberately kept

- **`time`** (top-level publish timestamps). npm's own abbreviated form omits it, but pnpr retains it because pnpm/pacquet read it for the `minimumReleaseAge` check.
- **`dist.signatures`** (ECDSA registry signatures). It binds `name@version:integrity` to the upstream registry's signing key and survives pnpr's `dist.tarball` rewriting (the signature covers the triple, not the URL). Nothing verifies it at install time today — `pnpm audit signatures` fetches its own *full* metadata — but keeping it leaves the door open to an optional client-side install-time registry-signature check, which is most valuable precisely on the pnpr path (an extra trust hop).
- **`dist.attestations`** — read by pacquet's `trustPolicy` verifier.

### Fixed along the way

Per-version **`libc`** is now forwarded alongside `os`/`cpu` — it was previously stripped. pnpm reads `libc` for optional-dependency platform filtering ([#9950](https://github.com/pnpm/pnpm/issues/9950)), so omitting it produced wrong installs through pnpr.

## Impact (measured)

Compact JSON size of the abbreviated packument, before vs. after this PR, on real registry metadata:

| Package | Versions | Before | After | Reduction |
|---|---:|---:|---:|---:|
| typescript | 3760 | 8379 KB | 1985 KB | **76%** |
| webpack | 875 | 1974 KB | 824 KB | **58%** |
| @types/node | 2336 | 2249 KB | 995 KB | **55%** |
| chalk | 43 | 46 KB | 21 KB | **53%** |
| react | 2817 | 2735 KB | 1509 KB | **44%** |
| express | 288 | 331 KB | 240 KB | **27%** |
| lodash | 117 | 67 KB | 49 KB | **26%** |
| **aggregate** | | **15.4 MB** | **5.5 MB** | **64%** |

Where the savings come from (react, % of the before size):

| Field dropped | Share |
|---|---:|
| `dist["npm-signature"]` | 36.0% |
| `dist.shasum` (integrity present) | 5.2% |
| `dist.unpackedSize` | 2.1% |
| `dist.fileCount` | 1.4% |
| `funding` / `devDependencies` / etc. | <0.1% |

`dist["npm-signature"]` dominates. (`dist.signatures`, kept, is a further ~18% that this PR intentionally leaves in place.)

**Methodology note:** the table simulates the per-version and `dist` trims against npm's *already-abbreviated* metadata, which does not contain top-level `readme`/`_id`/`_rev`. pnpr fetches the *full* upstream document and abbreviates it, so the real reduction is at least the figures above **plus** the full README text that is additionally dropped at the top level.

The win lands on the proxy/registry path (where the client still resolves locally); it does not affect the `/v1/install` accelerator path, where the client never receives a packument.

## Follow-up

Dropping `dist.tarball` (a further ~8% on react) is tracked separately in [#12164](https://github.com/pnpm/pnpm/issues/12164) — it first needs pnpm and pacquet to reconstruct the URL when it is absent.

## Safety

- Verified against the pnpm TS resolver: none of the dropped fields are read during resolution; `getIntegrity` returns `integrity` outright when present; `dist.signatures` / `npm-signature` / `unpackedSize` are consumed only by commands (`audit signatures`, `view`) that fetch their own full metadata, which pnpr serves unstripped.
- pacquet's `PackageVersion` has `#[serde(default)]` on `dev_dependencies` and ignores `shasum` / signature / size fields, so a pacquet-as-client deserialization stays valid.
2026-06-03 13:38:54 +02:00
Zoltan Kochan
a017bf3394 refactor: rename the agent client and agent setting to pnpr (#12155)
* refactor: rename the agent client + setting to pnpr

The pnpm-side client and its config setting still carried the old
"agent" name after the server moved to pnpr. Align both with pnpr (and
with pacquet, which already uses `pnprServer`):

- Move `agent/client` → `pnpr/client` and rename the package
  `@pnpm/agent.client` → `@pnpm/pnpr.client` (exported `AgentProject`
  type → `PnprProject`).
- Rename the config setting `agent` → `pnprServer` (`--pnpr-server`
  CLI flag), matching pacquet's setting name.
- Rename the internal install-path symbols and the user-facing log /
  error strings that mentioned "pnpm agent" to "pnpr".

No behavioral change — only names. The e2e suite now drives
`--config.pnprServer`.

* fix: forward optionalDependencies to the pnpr server

`PnprProject` and the install-request body only carried `dependencies`
and `devDependencies`, so a project's `optionalDependencies` were
dropped on the way to the pnpr server — it resolved as if they didn't
exist, producing a different lockfile than the local resolver.

Thread `optionalDependencies` through the client request shape, the
deps-installer single-project and workspace request builders, and the
pnpr server (`InstallRequestProject` / `InstallRequest` + the throwaway
manifest it writes for resolution). Adds an e2e case asserting an
optional dependency is resolved through `pnprServer`.
2026-06-03 12:01:48 +02:00
Zoltan Kochan
2b788d53fd refactor: replace the experimental pnpm-agent server with pnpr (#12151)
The experimental TypeScript `pnpm-agent` install-accelerator server is
superseded by the `pnpr` server, which implements the same protocol.
Remove `agent/server` and route the agent e2e test through pnpr.

The pnpm TypeScript client (`@pnpm/agent.client`) is kept and made
compatible with pnpr. The wire protocol carries the on-disk lockfile
format, while pnpm keeps an in-memory `LockfileObject` in process:

- Incoming: the agent's response lockfile is converted to the in-memory
  shape via `convertToLockfileObject`.
- Outgoing: the existing lockfile is read in its on-disk shape with the
  new `readWantedLockfileFile` and forwarded as-is — no in-memory
  round-trip.

pnpr now resolves multi-project workspaces by reconstructing the
workspace on disk (root manifest + `pnpm-workspace.yaml` + member
manifests) and letting pacquet's install path discover every importer.
Member dirs are written as quoted YAML scalars; importer dirs are
validated against path traversal (rejecting absolute, `..`, backslash,
and slashes-only inputs) and de-duplicated; synthetic manifest names
map injectively from dirs.

The CI test job builds the `pnpr` server from source (cached on the
Rust sources) so the agent e2e tests run against the current server.
The published `@pnpm/pnpr` is dropped as a test dependency: running the
suite already requires building `pnpr-prepare` from source (no npm
fallback), so the toolchain to build `pnpr` is always present, and the
published binary can predate the server protocol the tests exercise.
2026-06-03 01:11:24 +02:00
Zoltan Kochan
f429f93e8b feat(pnpr): support --lockfile-only on the pnpr install path (resolve-only mode) (#12148)
* feat(pnpr): support --lockfile-only on the pnpr install path

Add a server-side resolve-only mode so `--lockfile-only` (and the
`lockfileOnly` setting) is honored when a pnprServer / agent is
configured: resolve and write the lockfile, fetch nothing, link nothing.

The pnpr server skips the tarball fetch and file diff when the request
sets `lockfileOnly`, returning just the lockfile. The pnpr client and
the TypeScript agent client forward the flag and ignore any file/index
lines an older server still streams, keeping the store untouched. The
pacquet CLI no longer errors and stops after writing the lockfile.

Closes #12146

* fix(agent.client): observe fileDownloads in lockfile-only path; correct minimumReleaseAge unit doc

Address PR review: avoid a possible unhandled rejection if the NDJSON
stream errors after the L frame in lockfile-only mode, and fix the
`minimumReleaseAge` JSDoc (minutes, not seconds) to match the resolver
and the Rust client.
2026-06-02 21:14:37 +02:00
Zoltan Kochan
32c07bfee0 feat(pnpr): offload lockfile verification to the server (#12139) (#12144)
Closes #12139.

## What

When a `pnpr` server is configured, the client no longer runs `verifyLockfileResolutions` locally. It sends its on-disk lockfile plus its **full verification policy** to `/v1/install`; pnpr verifies that *input* lockfile under the **client's** policy *before* resolving, and streams back any violations so the client aborts with the identical `ERR_PNPM_*` diagnostic the local gate would have produced. This is faster (pnpr's packument cache is warm + shared) and removes the client's own registry-reachability requirement — it adds no new trust (the client already trusts pnpr to resolve and serve bytes).

All three phases from the issue, delivered together. **Rust-only**: `pacquet` client + `pnpr` server. The TS agent server is deprecated and the TS client already skips local verification, so no TS changes were needed.

## How

**Phase 1 — send lockfile + policy; pnpr verifies; client skips local verify**
- Protocol (`install_accelerator/protocol.rs`, mirrored in `pnpr-client`): `/v1/install` now carries `lockfile`, `frozenLockfile`, and the full policy (`minimumReleaseAge[Exclude|IgnoreMissingTime]`, `trustPolicy[Exclude|IgnoreAfter]`).
- `handle_install` verifies the input lockfile via `build_resolution_verifiers` + `collect_resolution_policy_violations` (under the client policy threaded into the server `config_for`) **before** resolving. On a violation it streams a `200` NDJSON `E` line of rendered violations; the client rebuilds the identical `VerifyError` (`PnprClientError::Verification`).
- The pacquet CLI sends `state.lockfile` + policy, drops the `trustPolicy: no-downgrade` guard (pnpr enforces it now — input-lockfile verifier for reused entries + resolver pick-time check for new ones), and sets `trust_lockfile: true` on the local materialization so it never re-verifies or touches the local `lockfile-verified.jsonl`.

**Phase 2 — `frozenLockfile` governs resolution reuse**
- `resolve.rs` seeds resolution from the input lockfile (frozen → as-is; non-frozen → reuse pins + resolve new).

**Phase 3 — SQLite whole-lockfile verdict cache on pnpr**
- New `install_accelerator/verdict_cache.rs`: SQLite-backed (reuses the existing `rusqlite` dep), keyed by `(lockfile hash, merged policy snapshot)`, hit = all verifiers `can_trust_past_check`. Only *passes* are cached (monotonic age + hash pins versions → time-correct without a cutoff, same property as the local cache); LRU cap, no TTL.
2026-06-02 19:26:09 +02:00
Minh Vu
71eea4d74e pnpr: avoid concurrent adduser overwrite (#12085)
* fix(pnpr): avoid concurrent adduser overwrite

* refactor(pnpr): extract verify_returning_user helper

Deduplicate the returning-user verify path shared by the early
existing-user check and the post-hash re-check, and document why the
concurrent regression test uses a higher bcrypt cost.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-01 17:11:36 +02:00
Zoltan Kochan
0a4d6656c9 feat(pacquet): implement the update command (#12102)
* feat(package-manager): implement the `update` command in pacquet

Port pnpm's `update` (aliases `up`/`upgrade`) onto pacquet's
always-fresh-resolve install path.

- Compatible bump: withhold matched names' lockfile pins from the
  preferred-versions seed (new `UpdateSeedPolicy`) so they re-resolve
  to highest-in-range; `package.json` is left untouched. This is what
  distinguishes `update` from `install`.
- `--latest`: fetch each matched direct dep's `latest` tag and rewrite
  the manifest range (`^v`, or exact under `--save-exact`), like `add`.
- Selectors: bare-name/glob patterns (`depth>0`, no `--latest`) match
  every locked package name at any depth; versioned (`foo@2`) or
  `--latest` selectors match direct deps only; `--latest` + spec is
  rejected with `ERR_PNPM_LATEST_WITH_SPEC`.
- CLI flags: `-L/--latest`, `-E/--save-exact`, `-P/-D/--no-optional`
  (faithful `makeIncludeDependenciesFromCLI`), `--depth`,
  `--lockfile-only`, `-i/--interactive` (dialoguer + inline outdated
  check).
- `--global` and `--workspace` error out for now: the global-dir and
  workspace-version-linking subsystems are not ported yet.

The resolver's `UpdateBehavior` tri-state and the npm resolver's
`include_latest_tag` already existed; this change drives them from a
CLI command.

Written by an agent (Claude Code, claude-opus-4-8).

* feat(package-manager): port more pnpm update tests + fix selector parsing

Reviewing pnpm's `update` test suite surfaced gaps:

- Fix `parse_update_param` to match pnpm's `parseUpdateParam`: search
  for the version `@` starting at index 2 for `!`-negated patterns (1
  otherwise), so `!@scope/pkg-*` is no longer wrongly split into
  pattern `!` + version `scope/pkg-*`. Negation selectors now work.
- Add `--no-save`: the range rewrites still drive resolution (lockfile
  updates) but `package.json` is not persisted. Mirrors pnpm's
  `updatePackageManifest: opts.save !== false`.
- Add `ERR_PNPM_NO_PACKAGE_IN_DEPENDENCIES`: a selector that matches no
  direct dependency under `--depth 0` (without `--latest`) now errors,
  matching pnpm; `--latest` with an unmatched selector is a no-op.

Ports the negation-pattern, `--no-save`, and no-package-in-deps tests
from pnpm's `installing/commands/test/update/update.ts`.

Written by an agent (Claude Code, claude-opus-4-8).

* perf(package-manager): avoid per-snapshot String alloc in update seed filter

`UpdateSeedPolicy::DropOnly`'s snapshot filter allocated a `String` for
every lockfile snapshot key. Parse the (small) update-target set to
`PkgName` once and compare against `key.name` directly. Addresses a
review comment on #12102.

Written by an agent (Claude Code, claude-opus-4-8).
2026-06-01 15:01:22 +02:00
Zoltan Kochan
54d2b57000 feat(pnpr): server-accelerated installs via pnprServer (endpoints + client + CLI) (#12077)
## What

Adds an opt-in **`pnprServer`** setting that offloads the slow part of an install — dependency resolution and computing which files the local store is missing — to a [pnpr](https://github.com/pnpm/pnpm/tree/main/pnpr) server, which streams back only the missing files. `node_modules` is still linked **locally** from the server-produced lockfile (like server-side rendering: the compute runs remotely, the result is materialized locally).

Realizes the agent concept from [RFC #9](https://github.com/pnpm/rfcs/pull/9), reworked around how it's actually used and rewritten in Rust on pacquet + pnpr.

## How it works

1. `pacquet install` (with `pnprServer` set) handshakes the server — `GET /-/pnpr` — to negotiate a protocol version.
2. It `POST`s `/v1/install` with the project's dependencies, the integrities already in its store, and **its own registry config** (default `registry`, `namedRegistries`, `overrides`, `minimumReleaseAge`).
3. The server resolves against *those* registries, fetches any uncached packages into its store, and streams NDJSON: `D` (missing file digests), `I` (pre-packed store-index entries), `L` (lockfile + stats).
4. The client downloads the missing files from `/v1/files` (gzip binary), writes them into its CAFS **by digest** (no re-hashing), writes the index entries, and runs a frozen install to link `node_modules` from the server's lockfile.

## Pieces

- **Server (`pnpr`)** — `GET /-/pnpr` handshake + `POST /v1/install` (NDJSON) + `POST /v1/files` (gzip), additive and opt-in alongside the npm-compatible API. Resolves against the client-sent registries, interning a `&'static Config` per distinct client config to bound the leak.
- **Client (`pacquet-pnpr-client`)** — `PnprClient`: reads store integrities, negotiates the protocol version, sends the registry config, parses the stream, materializes files + index entries, returns the lockfile. Rejects unrequested file entries and repairs truncated CAFS files.
- **CLI** — the `pnprServer` setting (`--pnpr-server`, `pnprServer:` in `pnpm-workspace.yaml`, `PNPM_CONFIG_PNPR_SERVER`). When set, `pacquet install` routes through the client and then links locally — pnpm's `install()` → `installFromPnpmRegistry` shape. `trustPolicy: no-downgrade` is refused (the server can't enforce it), matching pnpm's `TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`.

## Design notes

- **A distinct URL, not the registry.** The server resolves from the registries the client sends, so it's a compute service — not "a registry that resolves from itself" — which is why it's a separate `pnprServer` URL rather than reusing `registry`. The same server works for any client's registry setup, and a single pnpr can be both registry and `pnprServer`.
- **Handshake = version negotiation + fail-fast.** Explicit opt-in, so there's no silent fallback to local resolution; a non-pnpr server (404) or a version mismatch errors clearly.
- **Naming:** everything is `pnpr`; "agent" survives only in upstream citations (`@pnpm/agent.client`, the pnpm-agent PoC, pnpm's `TRUST_POLICY_INCOMPATIBLE_WITH_AGENT` error code).

## Tests

- `pacquet-pnpr-client`: resolve + download, multi-file package, warm-store no-op, and handshake rejection. The pnpr server's own uplink is left at the default, so resolution provably uses the **client-sent** registry.
- `pacquet-cli`: a real `pacquet install --pnpr-server <url>` against an in-process pnpr (resolving from the mocked fixtures registry) links `node_modules`.
- `pnpr`: `/v1/files` binary-framing round-trip + handshake route.

Full suites green; clippy / dylint (Perfectionist) / fmt / taplo / `cargo doc -D warnings` clean.

## Deferred

Auth/credential forwarding (so private/scoped registries resolve), `pacquet add` / `remove` via `pnprServer`, multi-project workspaces, and true streaming (responses are buffered today).

Refs https://github.com/pnpm/rfcs/pull/9
2026-05-31 16:50:20 +02:00
Zoltan Kochan
1db05c6fca fix: inconsistent resolution of a peer shared through a diamond (#12081)
* fix: inconsistent resolution of a peer shared through a diamond

When a package peer-depends both another package and one of that
package's own peer dependencies (e.g. @typescript-eslint/eslint-plugin
peer-depends both @typescript-eslint/parser and typescript, and
@typescript-eslint/parser peer-depends typescript), pnpm reused a
hoisted instance of the shared peer that was resolved against a
different version, producing an inconsistent resolution.

Close #12079

* test: cover the peer-diamond resolution in pnpm and pacquet

Add @pnpm.e2e/peer-diamond-* fixtures modeling #12079 (plugin
peer-depends both parser and ts; parser peer-depends ts) and
integration tests on both stacks. The pnpm test guards the fix; the
pacquet test confirms pacquet already resolves the diamond consistently
(its merge always prefers the node's own child).

* docs: fix grammar in changeset (peer-depends on both)
2026-05-31 16:03:12 +02:00
Zoltan Kochan
d99b725878 chore: license pnpr and pnpm-agent under PolyForm Shield 1.0.0 (#12082)
* chore(pnpr): license under PolyForm Shield 1.0.0

Relicense the pnpr/ subtree (the pnpm-compatible registry server) from
MIT to the source-available PolyForm Shield License 1.0.0. The rest of
the monorepo stays MIT. pnpr may be run, modified, and self-hosted for
any purpose except providing a product that competes with it.

- Add pnpr/LICENSE.md (PolyForm Shield 1.0.0).
- Override the inherited workspace MIT in the pnpr crates via
  license-file.
- Point the @pnpm/pnpr npm wrapper at the bundled LICENSE.md.
- Note the carve-out in the root README (the root LICENSE stays
  pristine MIT so license detection keeps recognizing it).

* chore(agent): license pnpm-agent under PolyForm Shield 1.0.0

Relicense the pnpm-agent server (agent/server) from MIT to the
source-available PolyForm Shield License 1.0.0, matching pnpr. The
@pnpm/agent.client package stays MIT so the agent protocol remains
openly implementable.

- Add agent/server/LICENSE.md (PolyForm Shield 1.0.0).
- Set the package license to "SEE LICENSE IN LICENSE.md".
- Exempt pnpm-agent from meta-updater's MIT normalization via a
  SOURCE_AVAILABLE_PKGS set, so lint:meta stays green.
- Note the carve-out in the agent/server README + add a changeset.

pnpm-agent is only a devDependency of the pnpm CLI, so no source-
available code ships in the MIT-licensed CLI artifact.

* docs(license): add contribution terms with relicensing grant for pnpr and pnpm-agent

Contributions to the source-available trees (pnpr/, agent/server) are
accepted under the same PolyForm Shield License plus a grant letting the
licensor relicense them under other terms. This preserves the option to
later relax to a more permissive source-available license or offer a
separate commercial license without per-contributor consent.

- Add pnpr/CONTRIBUTING.md and agent/server/CONTRIBUTING.md.
- Point to them from each tree's README license section.

* docs(license): add npm trademark/non-affiliation notice to pnpr and pnpm-agent

State that pnpr and pnpm-agent are not affiliated with or endorsed by
npm, Inc., GitHub, or Microsoft, and that "npm" is used only to describe
registry-protocol compatibility. Also add a License section to the
published @pnpm/pnpr npm wrapper README.
2026-05-30 22:51:14 +02:00
Khải
13b1b9aaa2 chore(rust/dylint): upgrade perfectionist to 0.0.0-rc.17 (#12070)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 20:58:31 +00:00
Zoltan Kochan
c5d9d3a8f3 refactor(pnpr): rename pnpm-registry to pnpr (#12069)
* refactor(pnpr): rename pnpm-registry to pnpr

Rename the registry server across the board to match the npm wrapper
package name, which was already `@pnpm/pnpr`.

- crate `pnpm-registry` -> `pnpr`, `pnpm-registry-fixtures` -> `pnpr-fixtures`
- binaries `pnpm-registry` -> `pnpr`, `pnpm-registry-prepare` -> `pnpr-prepare`
- module paths and log targets `pnpm_registry::*` -> `pnpr::*`
- binary-locating env vars `PNPM_REGISTRY_BIN` -> `PNPR_BIN`,
  `PNPM_REGISTRY_PREPARE_BIN` -> `PNPR_PREPARE_BIN`
- top-level directory `registry/` -> `pnpr/` (crates, npm wrapper, fixtures)

The registry-mock storage concept is intentionally left as-is:
`PNPM_REGISTRY_MOCK_PORT`/`PNPM_REGISTRY_MOCK_STORAGE`/`PNPM_REGISTRY_STORAGE`,
the `~/.cache/pnpm-registry/storage` path + benchmark cache keys, and the
external `pnpm-registry-mock` npm package referenced in test fixtures.

* style(pnpr): rustfmt import grouping after rename

* ci(pnpr): point typos at pnpr instead of removed registry dir

* chore(pnpr): update pre-push path filter from registry to pnpr
2026-05-29 20:02:10 +02:00