* fix: require provenance for trusted publisher evidence
* test: align provenance fixtures with registry types
* chore: include pnpm CLI in changeset
The repo guideline requires every changeset that touches a published
package to list the pnpm CLI explicitly so the fix appears in the CLI's
release notes.
* fix(resolving-npm-resolver): require provenance for trusted publisher evidence
Ports pnpm's fea5fd41da: `get_trust_evidence` now only returns
`TrustedPublisher` when the version carries both
`_npmUser.trustedPublisher` *and* `dist.attestations.provenance`.
Without the attestation, the publisher flag is metadata a staged
publish could mint, so it can't be ranked above plain provenance.
Refs #11887.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix: cap lockfile verification memory and add trustLockfile opt-out
Verifying a multi-thousand-entry lockfile against `minimumReleaseAge`
or `trustPolicy: no-downgrade` retained every fetched packument in a
per-install cache for the entire install. On large workspaces this
OOM'd CI runners with a 2GB heap cap. Project both caches down to just
the fields each check reads (per-version trust evidence + the `time`
map for trust; package-level `modified` + version-name set for the
abbreviated shortcut) so the bulk packument is GC'd as soon as the
fetch returns.
Also adds a `trustLockfile` setting (default `false`) that skips the
verification pass entirely for environments where the lockfile is
already part of the trusted base. Mirrored in pacquet. Closes#11860.
* perf: share resolver packument cache with the lockfile verifier
The verifier kept its own per-install dedup Maps and re-fetched every
packument the resolver had already pulled during the same install.
Plumb the resolver's per-install `PackageMetaCache` through to the
verifier (via `createNpmResolutionVerifier` / `build_resolution_verifiers`)
so a name already in the resolver's LRU short-circuits the verifier's
disk/network round-trip — fast path only, the cached document is
projected for the trust check so the verifier's memory footprint stays
bounded.
In pnpm, `installing/client` now constructs one LRU and hands it to
both `createResolver` and `createResolutionVerifiers`. In pacquet, the
`InMemoryPackageMetaCache` is lifted to `Install::dispatch` and passed
to both `build_resolution_verifiers` and `InstallWithFreshLockfile`.
`pnpm outdated` and `pnpm update --interactive` previously skipped runtime dependencies (`node`/`deno`/`bun` installed via the `runtime:` protocol). Both commands go through `outdatedDepsOfProjects` → `outdated()`, and the inner loop bailed out for anything `parseBareSpecifier` couldn't parse — which is everything `runtime:`-shaped. A runtime was only ever reported if the current install differed from the wanted lockfile entry, so the latest available version was never surfaced. The same gap silently affected `jsr:` and named-registry deps too.
Commits, smallest fix first → progressively cleaner architecture:
1. **`feat(outdated)`** — minimal fix: special-case runtime deps in `outdated.ts` so they appear in the table and the interactive update picker.
2. **`refactor(outdated)`** — per-resolver dispatch. Each protocol resolver gets its own "what's the latest?" function; `@pnpm/resolving.default-resolver` composes them.
3. **`refactor(outdated)`** — rename to `resolveLatest` (the function returns info regardless of whether the dep is outdated; "outdated" described a state, not an action).
4. **`refactor(outdated)`** — let the local-resolver own the `link:`/`file:` skip, drop the matching short-circuit in `outdated.ts`.
5. **`refactor(outdated)`** — slim `LatestQuery` / `LatestInfo` to the bare essentials; move `pickRegistryForPackage` into the npm-resolver where it belongs; derive `current`/`wanted` display from `pkgSnapshot.version` in `outdated.ts`.
6. **`chore(outdated)`** — drop stale tsconfig project reference left behind by #5.
7. **`refactor(outdated)`** — drop `wantedRef` from the query; resolvers detect protocol from `bareSpecifier` alone.
## Final architecture
`@pnpm/resolving.resolver-base` defines a single tiny protocol:
```ts
interface LatestQuery {
wantedDependency: WantedDependency
compatible?: boolean
}
interface LatestInfo {
latestManifest?: PackageManifest
}
type ResolveLatestFunction = (query: LatestQuery, opts: ResolveOptions) =>
Promise<LatestInfo | undefined>
```
- `undefined` from a resolver means "I don't claim this dep — try the next one."
- `{}` means "I claim it, but I can't tell you what's latest" (policy-blocked, network unavailable, or a protocol with no concept of latest — git/tarball).
- `{ latestManifest }` is the happy path.
Each protocol resolver (npm/jsr/named-registry, git, tarball, local, node/bun/deno runtimes) exports its own `resolveLatest*` function alongside its `resolve*`. `@pnpm/resolving.default-resolver` composes them into a single first-match dispatcher, surfaced through `@pnpm/installing.client` as `createResolver(...).resolveLatest`.
`outdated.ts` is protocol-agnostic: dispatches, then derives `current`/`wanted` display from `pkgSnapshot.version` (falling back to the raw ref for URL-shaped refs where the URL is the only diff signal between commits), uses raw `wantedRef !== currentRef` for the lockfile-shifted check, and pulls `packageName` from `dp.parse(relativeDepPath).name` so aliased deps still report under the real package name.
Per-resolver responsibilities:
- **npm-resolver** (`resolveLatestFromNpm` / `resolveLatestFromJsr` / `resolveLatestFromNamedRegistry`): match their respective spec shapes, call the matching `resolveFromX` with `'latest'` (or the original spec under `--compatible`), handle `MINIMUM_RELEASE_AGE_VIOLATION` and `ERR_PNPM_NO_MATCHING_VERSION` so policy-blocked deps don't surface as available updates. Picks the per-package registry internally via its ctx.
- **node/bun/deno runtime resolvers**: claim deps via `bareSpecifier.startsWith('runtime:')` + alias match, query their release sources for the latest version (only the version — no asset-hash fetches), return `{ latestManifest }`.
- **git / tarball resolvers**: claim deps via spec shape, return `{}` (no concept of "latest"); the caller still surfaces a ref-mismatch report if the lockfile shifted to a different commit/URL.
- **local-resolver**: returns `undefined` so `link:`/`file:`/`workspace:` deps fall through and get silently skipped.
Three coordinated changes that close the silent-bypass gap in loose `minimumReleaseAge` mode AND the discover-by-loop UX problem in strict mode (#10488), plus a parallel hardening of the lockfile verifier:
1. **Auto-collect into `minimumReleaseAgeExclude` (loose mode)** — fresh resolutions that fall back to a version newer than the cutoff are auto-recorded into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists what was persisted. The workspace manifest writer dedupes against existing entries.
2. **Lockfile verifier runs in loose mode too** — `createNpmResolutionVerifier` no longer gates on `minimumReleaseAgeStrict`. With auto-collect keeping the exclude list explicit, every accepted-immature pin must be on the list — same contract strict mode enforces. Lockfiles produced under a weaker (or absent) policy that still hold immature entries are rejected the same way strict mode would.
3. **Strict mode prompts on the aggregate set instead of throwing on the first** — the resolver always collects every immature direct and transitive in one pass; the install command's `handleResolutionPolicyViolations` checkpoint decides what to do with the set. Interactive (TTY) prompts the user once with the full list (default = No) and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues, persisted at the end. Decline → resolution aborts before the lockfile, package.json, or modules dir is touched. Non-interactive (CI) keeps `ERR_PNPM_NO_MATURE_MATCHING_VERSION` as the exit code but lists every offending entry instead of just the first one the resolver happened to hit.
4. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).
The steady-state flows:
- **Loose mode, `pnpm add foo@immature`**: lockfile clean, verifier no-op, resolver picks via lowest-version fallback, `foo@immature` lands in `minimumReleaseAgeExclude`, install succeeds. Subsequent `pnpm install --frozen-lockfile` in CI verifies against the populated list and succeeds.
- **Strict mode (interactive), security bump to `next@15.5.9`**: resolver collects `next@15.5.9` AND every immature `@next/swc-*@15.5.9` shim. pnpm prompts once with the full list. User approves → install completes, all entries persisted in `pnpm-workspace.yaml`. CI then runs the populated config cleanly.
- **Strict mode (non-interactive / CI)**: aborts with `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing every immature entry's `name@version` and publish time — no more discover-by-loop dance.
- **Teammate commits a poisoned lockfile**: single-policy batches reject with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` (or `ERR_PNPM_TRUST_DOWNGRADE`); a batch that trips both policies escalates to the generic `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION` and lists each entry's per-policy code in the breakdown.
### Implementation
- The npm resolver always falls back to the lowest matching version when no mature version satisfies the range, and flags the result with `ResolveResult.policyViolation` instead of throwing `NO_MATURE_MATCHING_VERSION`. `deferImmatureDecision` and `strictPublishedByCheck` are gone — every caller (install, dlx, outdated, self-update) inspects the violation and decides what to do.
- `policyViolation` flows from `ResolveResult` → `PackageResponse.body.policyViolation` → a shared accumulator in `ResolutionContext` → the `resolutionPolicyViolations` field on `resolveDependencyTree`'s return → out through `mutateModules` / `addDependenciesToPackage` to the install command.
- The violation type lives in `@pnpm/resolving.resolver-base` as `ResolutionPolicyViolation`; the npm resolver exports the two built-in codes (`MINIMUM_RELEASE_AGE_VIOLATION_CODE`, `TRUST_DOWNGRADE_VIOLATION_CODE`) as constants so consumers reference one source of truth.
- `handleResolutionPolicyViolations` runs between `resolveDependencyTree` and `resolvePeers` — the resolver-agnostic checkpoint where the install command's plan prompts (TTY) or aborts (no-TTY) with the full violation list.
- `setupPolicyHandlers` (in `installing/commands/src/policyHandlers.ts`) composes per-policy handlers behind a uniform plan interface: each handler has its own `handleResolutionPolicyViolations` (filter by code, decide what to do) and `pickManifestUpdates` (return a typed `WorkspaceManifestPolicyUpdates` patch the install command spreads into `updateWorkspaceManifest`). Today the only registered handler is `createMinimumReleaseAgeHandler` — strict + TTY prompts via `enquirer`, strict no-TTY throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` with every entry listed, loose mode auto-persists at the tail. Strict + `--no-save` is rejected up-front via `ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE`. Future policies plug in via a sibling factory + push into the handlers list, with no changes to `installDeps.ts` / `recursive.ts`.
- `installDeps` / `recursive` drain `pickManifestUpdates` after install and spread the patch into `updateWorkspaceManifest`. Plain `pnpm install` (no `--update`, no params) now still updates the workspace manifest when any handler contributes a patch. The `install` command's CLI schema gained `save: Boolean` so `--no-save` actually flows through to `opts.save = false` instead of being silently dropped by nopt.
- `makeResolutionStrict` (in `installing/client`) wraps a `ResolveFunction` and rethrows any `policyViolation` as a `PnpmError`. Used by `dlx` and `self-update` under strict `minimumReleaseAge` OR `trustPolicy: 'no-downgrade'`, since one-shot callers have nowhere to defer a violation to. Violation-code → error-code mapping lives in one place so future violation kinds get consistent UX.
- `createNpmResolutionVerifier` extends its check to `trustPolicy: 'no-downgrade'` — same per-entry fan-out, same cache key, sharing the full-metadata fetch with the maturity check. Trust-fetch errors now propagate up so the violation reason carries the underlying message (network code, 404 detail) instead of a generic "metadata is unavailable".
- `verifyLockfileResolutions`'s aggregate throw uses the per-policy code when every violation in the batch shares it, and escalates to a generic `LOCKFILE_RESOLUTION_VERIFICATION` (with per-entry codes in the breakdown) for mixed batches.
- The pnpm agent path refuses installs under `trustPolicy: 'no-downgrade'` (`ERR_PNPM_TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`) — the agent has no server-side counterpart to that check yet, so silently allowing it would land a lockfile the local verifier would later reject. `minimumReleaseAge` is forwarded to the agent and enforced server-side, so that combination is fine.
### Pacquet parity
Pacquet only carries a stub reference to `minimumReleaseAgeExclude` (see `pacquet/crates/package-manager/src/version_policy.rs`); the broader `minimumReleaseAge` and `trustPolicy` policies aren't ported yet, so this feature is outside pacquet's current surface area. It'll come along when pacquet ports the policies.
### Closes
- Closes#10488 (resolves the discover-by-loop dance for security bumps without needing `withTransitives`).
Follow-up to #11691 — item 2 from #11687, plus a related shortcut.
## What
When the `minimumReleaseAge` lockfile verification gate needs to know when a version was published, it used to fetch a multi-MB full metadata document per package just to read one timestamp. This PR replaces that single-step path with a four-layer lookup that pays the cheapest viable source first:
1. **Abbreviated metadata's `modified` field** — the resolver already fetches this for resolution. If the package as a whole hasn't been modified within the policy cutoff, every version it contains is at least that old; return `modified` as a conservative upper-bound and skip the rest of the chain.
2. **Local `FULL_META_DIR` mirror** — exact per-version times if a previous verification populated it.
3. **npm attestation endpoint** (`/-/npm/v1/attestations/<name>@<version>`) — a tens-of-KB Sigstore bundle whose Rekor inclusion time stands in for publish time. Wins on cold cache when the package was published with provenance.
4. **Full metadata fetch** — last resort.
## Why
The verification cache from #11691 made repeat installs against an unchanged lockfile effectively free. The remaining cost is the *first* verification on a fresh CI runner with no restored cache — particularly `pnpm install --frozen-lockfile`, where every locked package's publish timestamp has to be confirmed. Fetching the full metadata document for each package is wasteful when:
- The resolver has usually already cached abbreviated metadata, whose `modified` field alone is enough to clear stable packages (the common case).
- For recently-modified packages, the per-version attestation endpoint is orders of magnitude smaller than full metadata.
## How
### Abbreviated `modified` shortcut
`fetchFullMetadataCached` is refactored to share an internal helper with a new `fetchAbbreviatedMetadataCached`. Both do conditional GETs against their respective on-disk mirrors. On a non-frozen install the abbreviated mirror is already populated by the resolver, so the shortcut hits the local cache at headers-only cost. On `--frozen-lockfile` the fetch is still cheaper than full metadata.
If `Date.parse(modified) < cutoff`, return `modified` — it's an upper bound on every version's publish time in this package, and the verifier's `published < cutoff` check passes trivially.
### Attestation endpoint
`fetchAttestationPublishedAt` (new module) hits `/-/npm/v1/attestations/<name>@<version>`, parses the response, and reads the earliest `bundle.verificationMaterial.tlogEntries[].integratedTime` across the attestation bundles. That's the Rekor inclusion time — a couple of seconds after publish, well within tolerance for a policy that operates in minutes/hours/days. Returns `undefined` on 404 / network error / malformed body so the caller falls back.
### Per-install dedup
The lookup carries a `PublishedAtLookupContext` with four memo maps: abbreviated meta per (registry, name), local full meta per (registry, name), full meta network fetch per (registry, name), final published-at per (registry, name, version). Verifying many versions of the same package only pays the disk/network costs once.
## Trust model
**No Sigstore signature verification on the attestation path.** The trust model is identical to reading the registry's `time` field on the full metadata document — we already trust the registry to serve correct publish timestamps for the gate's purpose. The win is purely bandwidth.
Full Sigstore verification (Fulcio cert chain + Rekor inclusion proof) would harden the timestamp against a compromised registry. It pulls in the `sigstore` npm package and the TUF root — a separate dependency-surface discussion, parked as future work.
## Tests
- **13 unit tests** in `resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts`: ISO timestamp extraction, URL construction (scoped + unscoped), 404 / 5xx / network error / malformed JSON / missing fields → undefined, earliest-of-multiple-attestations, defensive number-as-integratedTime, auth header forwarding, trailing-slash normalization.
- Existing `minimumReleaseAge` + `verifyLockfileResolutions` integration suites (45 tests) still pass — the fallback chain preserves end-to-end behavior when the new shortcuts don't apply.
Closes#11687.
## What
Cache the result of the post-resolution lockfile verification gate (#11583) so repeat installs against an unchanged lockfile skip the per-package registry round trips entirely. Persisted as JSON Lines at `<cacheDir>/lockfile-verified.jsonl`.
The cache layer is policy-neutral. Today there's one verifier (`minimumReleaseAge`); future resolver-side verifiers (jsr trust, attestation, …) plug in by declaring their own `policy` slot and `canTrustPastCheck` comparator — no install-side changes.
## Why
#11583 re-hits the registry on every install for every locked (name, version) pair. On warm/repeat installs where the lockfile hasn't moved, that's a stack of per-package round trips with nothing to show for them. This change makes the steady-state case effectively free without weakening the protection — the gate still runs in full whenever the lockfile changes, any verifier's policy tightens, or no record exists.
## How
### Cache lookup, in order
The cache is **indexed by content hash** so git worktrees with identical lockfile bytes share a cache entry. A secondary path-keyed index drives the same-machine stat shortcut.
1. **`stat()` shortcut** — when a previous record for this exact `lockfilePath` matches today's `size + mtime + inode`, trust the cached hash without reading anything. Zero I/O beyond the stat. Microseconds.
2. **Content lookup** — hash the in-memory lockfile (not the file bytes — we already have the parsed object) and look up by content hash. Catches worktrees (same content, different path) and CI checkouts (same content, reset stat). On hit, append a refreshed path/stat entry so the next install at this path takes the stat shortcut.
3. **Any active verifier rejects the cached `policy`** — run the full gate.
4. **No record** — run the full gate.
The in-memory object is hashed with `hashObject` from `@pnpm/crypto.object-hasher` (streaming, key-order-stable).
### Record shape
```json
{
"lockfile": {
"hash": "<sha256 base64>",
"path": "/abs/path/to/pnpm-lock.yaml",
"size": 154,
"mtimeNs": "1736245123000000000",
"inode": "12345"
},
"verifiedAt": "2026-05-17T...",
"policy": { "minimumReleaseAge": 1440 }
}
```
`policy` is the union of every active verifier's `policy` contribution. Verifiers checking the same logical policy (e.g. `minimumReleaseAge` honored by multiple registries) name it the same and share the slot — no resolver namespacing.
### File semantics
- **Sync fs throughout** — the cache is consulted once before verification fan-out and recorded once after. No concurrent install work to overlap with; keeping the call sites straight-line.
- **JSONL appends are atomic** on POSIX/NTFS, so parallel pnpm processes (monorepo installs, CI matrices sharing a cache) write without coordination. Latest record per `(path, hash)` tuple wins on read.
- **Bounded file** — capped at ~1000 entries; compaction is triggered by a single `stat()` of the cache file (1.5 MiB byte budget) so we never parse the file on the steady-state path. When triggered, the tail is rewritten via tempfile + rename.
- **No record on rejection** — a failing verification deliberately doesn't write a record; the next install must rerun the gate.
- **Single hash per install** — the in-memory hash is computed lazily and reused: `tryLockfileVerificationCache` returns the precomputed stat+hash to `recordVerification` on a miss, and the stat-shortcut hit forwards the cached record's hash unchanged.
## Plumbing
The verifier contract changed alongside the cache to make this composable without install-side knowledge of each policy:
- **`@pnpm/resolving.resolver-base`** — `ResolutionVerifier` is now `{ verify, policy, canTrustPastCheck }` (was a bare function in #11583). Each resolver-side verifier owns its policy snapshot and the comparator that decides whether a cached policy is still trustworthy.
- **`@pnpm/resolving.npm-resolver`** — `createNpmResolutionVerifier` returns the new shape: `policy: { minimumReleaseAge }`, `canTrustPastCheck` reads `minimumReleaseAge` from the merged cached bag.
- **`@pnpm/resolving.default-resolver`** — `createResolutionVerifier` (singular, returning a combined function) → `createResolutionVerifiers` (plural, returning a `ResolutionVerifier[]`). No combinator; each verifier handles its own protocol short-circuit inside `verify`, so dispatch happens naturally at the install side.
- **`@pnpm/installing.client`** — `Client.verifyResolution?` → `Client.resolutionVerifiers: ResolutionVerifier[]`. Same rename propagates through `@pnpm/store.connection-manager`, `@pnpm/testing.temp-store`, and `StrictInstallOptions`.
- **`@pnpm/installing.deps-installer`** — new `verifyLockfileResolutionsCache.ts` (`tryLockfileVerificationCache` + `recordVerification`). `verifyLockfileResolutions` takes the verifier list plus `cacheDir` + `lockfilePath` as flat options; the cache fires when both are present, otherwise the gate runs without memoization. The dedup key for in-flight candidates includes a serialization of `resolution` so two entries sharing a (name, version) but pinned via different protocols don't collapse.
Breaking but safe — `@pnpm/resolving.npm-resolver` hasn't been released since #11583 introduced the verifier abstraction, so no downstream consumer is on the old shape.
## Tests
- **17 unit tests** in `verifyLockfileResolutionsCache.ts`: cache miss/hit, stat shortcut, size mismatch falling through to hash lookup, hash-fallback on reset stat, content change with matching size, stricter/weaker policy, missing-field policy rejection, multi-verifier policy merge (shared field stored once), worktree case (same content, different path), JSONL append semantics, malformed-line tolerance.
- **12 integration tests** in `verifyLockfileResolutions.ts`: dedup of peer/patch-suffix variants, distinct-resolution dedup at the same (name, version), stable violation ordering, the 20-entry cap, multi-verifier fan-out (first failure wins), cache short-circuit on a passing run, no cache write on a rejecting run, empty-verifier-list passthrough.
- **1 e2e test** in `pnpm/test/install/minimumReleaseAge.ts`: bundled CLI plumbing — install once to seed the lockfile, enable `minimumReleaseAge` + `cacheDir`, install again, assert the cache file lands at `<cacheDir>/lockfile-verified.jsonl` with the documented record shape.
- Existing `minimumReleaseAge` (13) and `frozenLockfile` (12) suites still pass.
Closes#10438.
## What
Re-verify every entry in `pnpm-lock.yaml` against the policies the resolver chain was configured with — today: `minimumReleaseAge` in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored.
## Why
The policy only fires while pnpm is *choosing* a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later `pnpm install` (including `--frozen-lockfile` and `pnpm fetch`) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide.
The threat model is **a lockfile someone else resolved**, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in [oven-sh/bun#30526](https://github.com/oven-sh/bun/pull/30526); this PR is the pnpm side.
## How
The fix introduces a generic `ResolutionVerifier` abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a `resolve` function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface.
- **`@pnpm/resolving.resolver-base`** exports the `ResolutionVerifier` / `ResolutionVerification` types — the shared contract.
- **`@pnpm/resolving.npm-resolver`** exports `createNpmResolutionVerifier`. Returns `undefined` when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles `minimumReleaseAgeExclude`, routes through named-registry prefixes (built-ins like `gh:` merged in), and uses `fetchFullMetadataCached` to fetch full registry metadata — decoupled from the resolver pipeline so neither `peekManifestFromStore` nor abbreviated metadata can hide the publish timestamp.
- **`@pnpm/resolving.default-resolver`** exports `createResolutionVerifier`, a combinator that asks each underlying verifier (today: npm) if it has work and returns `undefined` when none does. Designed so that adding more verifiers later doesn't change the install side.
- **`@pnpm/installing.client`** exposes `verifyResolution` on `Client`, built from the same `fetchFromRegistry` / `getAuthHeader` the resolver chain already uses — **no second fetcher is constructed**.
- **`@pnpm/store.connection-manager`** and **`@pnpm/testing.temp-store`** surface `verifyResolution` alongside the store controller they hand back, so it reaches `mutateModules` through the existing plumbing.
- **`@pnpm/installing.deps-installer`** gains one option on `StrictInstallOptions`: `verifyResolution?: ResolutionVerifier`. `mutateModules` invokes `verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)` **once**, right after `getContext` returns the on-disk lockfile and before any path branches. When the verifier is `undefined`, the call is a no-op. The iteration is policy-neutral: dedupes by `(name, version)`, applies `pLimit(16)`, sorts violations stably, caps the printed list at 20 with an `…and N more` summary, throws a `PnpmError` carrying the verifier-supplied error code.
The error includes a recovery hint that points at `pnpm clean --lockfile` followed by `pnpm install` — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution.
## Tests
- **9 unit tests** for `verifyLockfileResolutions` against a mock `ResolutionVerifier` — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in `PnpmError`.
- **13 integration tests** in `installing/deps-installer/test/install/minimumReleaseAge.ts` via the real `install()` entry — `testDefaults()` wires `verifyResolution` from `createTempStore` → `createClient`, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, `minimumReleaseAgeExclude`, the strict-mode toggle, the existing `minimumReleaseAge` resolver-side suite, and a `pnpm add` scenario where a pre-existing entry would otherwise survive resolution.
- **3 e2e tests** in `pnpm/test/install/minimumReleaseAge.ts` against the bundled CLI: rejection path with the right `ERR_PNPM_*` code and `pnpm clean --lockfile` hint in output, `minimumReleaseAgeExclude` honored, and the strict-off path (which now requires an explicit `minimumReleaseAgeStrict: false` since the config reader auto-enables strict mode when `minimumReleaseAge` is set).
- Existing `frozenLockfile` suite (12 tests) and npm-resolver suite (179 tests) still pass.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix(npm-resolver): dont rethrow ERR_PNPM_MISSING_TIME from version-spec cache
* fix(npm-resolver): upgrade cached abbreviated metadata on 304 for minimumReleaseAge
* fix(npm-resolver): expand abbreviated-meta upgrade to in-memory cache and preferOffline paths
* fix(npm-resolver): address Copilot review feedback on pickPackage
- Extract `persistUpgradedMeta` helper and call it from all three sites
(in-memory cache hit, preferOffline disk-cache hit, 304 path) so a fresh
process doesn't repeat the upgrade fetch.
- Forward `etag`/`modified` to the upgrade fetch in
`maybeUpgradeAbbreviatedMetaForReleaseAge` so the registry can answer 304.
- Extract `shouldRethrowFromFastPathCache` so the two fast-path catch sites
can't drift on the MISSING_TIME-vs-strict invariant.
- Document the deliberate choice to upgrade-fetch when `meta.modified` is
absent or unparseable (correctness over saving a network call).
- Add a companion test that exercises the catch fix with the default
`ignoreMissingTimeField` so the invariant holds regardless of that flag.
- Fix the existing `bareSpecifier: '3.1.0'` test setup: 3.1.0 was published
2016-01-11, after the test's `publishedBy` of 2015-08-17, so strict mode
correctly rejected it. Switch to 3.0.0 (released 2015-07-10).
* chore(npm-resolver): replace 'unparseable' with 'malformed' for cspell
* style(npm-resolver): declare pickPackage helpers after their caller
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Resolves the 15 open alerts on https://github.com/pnpm/pnpm/security/code-scanning by addressing all four categories that CodeQL flagged.
### Prototype-polluting assignment (3 alerts, product code)
- `pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts`: the inner write now dispatches over a literal `switch` on `runtimeName`, so the assignment is always keyed by `'node' | 'deno' | 'bun'`.
- `pkg-manifest/utils/src/updateProjectManifestObject.ts`: added an `isProtoPollutionKey` barrier at the top of the loop so `packageSpec.alias` can never reach the dynamic property write with `__proto__` / `constructor` / `prototype`.
- `installing/deps-installer/src/uninstall/removeDeps.ts`: the package list is filtered through `isProtoPollutionKey` once up front, and the dependency record is captured into a local before the loop.
### Polynomial ReDoS (2 alerts)
- `deps/inspection/list/src/renderDependentsTree.ts`: `replace(/\n+$/, '')` swapped for a constant-time `charCodeAt` trim.
- `resolving/npm-resolver/src/fetch.ts`: removed the super-linear-backtracking `semverRegex` and replaced it with an O(n) `stripTrailingSemverSuffix` that splits on the rightmost `@` and `semver.valid`s, with a digit-block fallback so `foo1.0.0`-style names still produce the existing "Did you mean foo?" hint.
### Bad code sanitization (8 alerts, test infrastructure)
- `__utils__/test-ipc-server/src/TestIpcServer.ts`: the `JSON.stringify(...).slice(1, -1)` smell at the source of all 8 test-file alerts is gone. Both `sendLineScript` and `generateSendStdinScript` now build the JS source with plain `JSON.stringify` and delegate shell wrapping to a new `wrapNodeEval` helper that escapes `\\` and `"` for the outer double-quoted shell argument.
### Incomplete sanitization (2 alerts, test file)
- `releasing/commands/test/publish/oidcProvenance.test.ts`: `.replace('/', '%2f')` → `.replaceAll(...)` on both flagged lines.
## Summary
- The local resolver's path-shape match was claiming any specifier containing `/` as a local directory, so `pnpm add bit:@teambit/bit` (with `bit` configured under `namedRegistries`) installed a bogus link to `bit:@teambit/bit/` instead of resolving from the configured registry.
- Split the local resolver into two exports: `resolveFromLocalScheme` (handles `file:`/`link:`/`workspace:`/`path:`) and `resolveFromLocalPath` (path-shape match — tarball extension, `path.sep`, `isFilespec`). `resolveFromLocal` is removed.
- Re-order the default-resolver chain so the scheme pass runs *before* `resolveFromNamedRegistry` and the path pass runs *after*. Explicit local protocols still win even when a user configures a colliding `namedRegistries` alias; named-registry aliases reach their configured URL.
Repro before the fix:
```
$ cat pnpm-workspace.yaml
namedRegistries:
bit: https://node-registry.bit.cloud/
$ pnpm add bit:@teambit/bit
[WARN] Installing a dependency from a non-existent directory: /private/tmp/.../bit:@teambit/bit
dependencies:
+ bit 0.0.0 <- bit:@teambit/bit
```
After the fix, the same command resolves `@teambit/bit 1.13.173` from `https://node-registry.bit.cloud/` and writes `"@teambit/bit": "bit:^1.13.173"` to `package.json`.
* **New Features**
* `pnpm view` now shows publish age (e.g., "published 2 hours ago") and, when available, publisher attribution ("by …"); invalid or future timestamps fall back to "just now".
* Console styling for package metadata (name/version, license, counts, links, dependencies, maintainers) was improved for readability.
* **Tests**
* Added tests verifying the published timestamp and publisher attribution appear in `pnpm view` output.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- Upgrade `@pnpm/semver-diff` and `@pnpm/colorize-semver-diff` to v2, which expose the helpers as named exports.
- Update the call sites in `@pnpm/deps.inspection.commands` and `@pnpm/installing.commands` from `semverDiff.default(...)` / `colorizeSemverDiff.default(...)` to plain `semverDiff(...)` / `colorizeSemverDiff(...)`.
- Refactor `buildPkgChoice` in `getUpdateChoices.ts` to build the row as a `string[]`. Previously the row was an object whose values relied on `nextVersion` being inferred as `any` (a side effect of the broken `.default` access poisoning the type) — that masked `outdatedPkg.current` and `outdatedPkg.workspace` being `string | undefined`. With the v2 named imports the types tighten up, and `Object.values(lineParts)` would no longer assign cleanly to `string[]`.
The previous v1 packages exported their helpers as `module.exports.default = fn`, so `.default(...)` only worked through the legacy CJS interop — and it broke under Node.js ESM (which is what the Jest runner uses with `--experimental-vm-modules`). Most of the `deps/inspection/commands` outdated tests had been silently failing on `main` with `TypeError: semverDiff.default is not a function`; this change brings them back.
* fix(git-resolver): avoid encoded slash in GitLab tarball URL
hosted-git-info's default GitLab tarball URL routes through
`/api/v4/projects/<user>%2F<project>/...`. The `%2F` survives into the
virtual store directory name (depPathToFilename only escapes raw `/`,
not `%`), and Node refuses to import any module whose path contains an
encoded slash. The same URL is also intermittently rejected by GitLab
with a 406.
Override the GitLab tarballtemplate to the `/-/archive/` URL, which works
for both public and private repos and contains no encoded slashes.
Closes#11533
* test: avoid cspell-flagged words
* test: keep existing gitlab assertions, only add new ones
Restore the skipped tests' original API-URL assertions; they document the
old expected shape and weren't running anyway. Add the new `/-/archive/`
URL to the pick-fetcher fixture as an additional case so both shapes are
exercised.
For git-hosted tarballs (`codeload.github.com` / `gitlab.com` / `bitbucket.org`) the fetcher dropped the integrity it computed while downloading, so the lockfile only ever stored the URL. A compromised git host or man-in-the-middle could serve a substituted tarball on subsequent installs and pnpm would install it — the lockfile had no hash to compare against.
This pins the SHA-512 SRI of the raw tarball in the lockfile, in the same `sha512-<base64>` form npm-registry tarballs use. The only difference is the source: for npm we pass through `dist.integrity`, for git we compute it locally from the downloaded buffer. Subsequent installs validate the download against that integrity in the worker (`addTarballToStore` → `parseIntegrity` → hash compare), so a tampered tarball fails with `TarballIntegrityError`.
## Why git-hosted stays on `gitHostedStoreIndexKey`
The lockfile pins integrity for security, but the *store key* for git-hosted resolutions stays on `gitHostedStoreIndexKey(pkgId, { built })` rather than collapsing under the integrity-based key. Reason: git-hosted tarballs are post-processed (`preparePackage` / `packlist`), so the cached file set depends on whether build scripts ran during fetch. The integrity-only key would fold the built and not-built variants into a single slot, letting one overwrite the other and serving the wrong content if `ignoreScripts` was toggled between runs. Keeping git-hosted on the existing key shape preserves that dimension; the integrity is still validated on every fresh download.
## How the routing stays clean
The naive way to express "use gitHostedStoreIndexKey for git-hosted, integrity key for npm" is to call `isGitHostedPkgUrl(resolution.tarball)` everywhere a store key is computed — fragile, scattered, and easy to forget when adding new readers (Copilot caught two of those during review). Instead, a typed annotation: `TarballResolution` gets an optional `gitHosted: boolean` field. The git resolver sets it; the lockfile loader (`convertToLockfileObject`) backfills it for entries written by older pnpm versions; `toLockfileResolution` carries it through on serialize. Every consumer reads `resolution.gitHosted` directly. URL detection lives in exactly two places — the resolver and the loader — instead of seven.
## Changes
### Security fix
- `fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts` — return the `integrity` that the inner remote-tarball fetch already computed (was being silently dropped by the destructure).
### Lockfile schema (additive)
- `@pnpm/lockfile.types` and `@pnpm/resolving.resolver-base` — `TarballResolution` gains optional `gitHosted: boolean`.
- `@pnpm/resolving.git-resolver` — sets `gitHosted: true` on every git-hosted tarball it produces.
- `@pnpm/lockfile.fs` (`convertToLockfileObject`) — backfills the field on load for older lockfiles via inlined URL detection.
- `@pnpm/lockfile.utils` (`toLockfileResolution`, `pkgSnapshotToResolution`) — preserve / read the field.
### Store-key consumers (now one-line typed reads, dropped the URL-sniffing dep)
- `installing/package-requester` (`getFilesIndexFilePath`)
- `store/pkg-finder` (`readPackageFileMap`)
- `modules-mounter/daemon` (`createFuseHandlers`)
- `building/after-install` (side-effects-cache lookup + write)
- `store/commands/storeStatus`
- `installing/deps-installer` (agent-mode store-controller wrapper)
### Fetcher routing
- `fetching/pick-fetcher` — `pickFetcher` prefers `resolution.gitHosted`; URL fallback retained for ad-hoc resolutions.
### Tests
- New integrity-validation test in `tarball-fetcher` (mismatched `integrity` on the resolution must throw `TarballIntegrityError`).
- New git-hosted lookup test in `pkg-finder` asserting routing through `gitHostedStoreIndexKey` even when integrity is present.
- New `toLockfileResolution` test asserting `gitHosted: true` flows through serialization.
- `fromRepo.ts` lockfile snapshot updated for the now-pinned integrity + `gitHosted: true`.
- `git-resolver` tests updated to assert `gitHosted: true` in produced resolutions.
This is consistent with #9358, but implements support for the GitHub Packages npm registry and, more broadly, for vlt-style https://docs.vlt.sh/cli/registries for any registry.
This PR adds a built-in gh: specifier that resolves against the GitHub Packages npm registry, plus a namedRegistries config key so a project can map its own aliases to arbitrary registries. A project can mix public npm packages and private GitHub Packages (or self-hosted) ones without applying a scope-wide registry override to every @scope/* package.
- pnpm add gh:@acme/private writes "@acme/private": "gh:^1.0.0" and resolves from https://npm.pkg.github.com/.
- pnpm add gh:@acme/private@^1.0.0 (with or without an alias) is also supported. Aliased form writes "my-alias": "gh:@acme/private@^1.0.0".
- Auth comes from the existing per-URL .npmrc mechanism, e.g. //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}. No new auth surface.
- @github is intentionally not defaulted to https://npm.pkg.github.com/ - hardcoding that would hijack installs of the public @github/* packages on npmjs.org (e.g. @github/relative-time-element) for users without a scope-wide override. Use gh: to install from GitHub Packages, or configure @github:registry=... yourself if that's really what you want.
- Additional named registries (a self-hosted proxy, GitHub Enterprise Server, etc.) can be configured in pnpm-workspace.yaml:
```yml
namedRegistries:
gh: https://npm.pkg.github.example.com/ # optional: overrides the built-in `gh` alias for GHES
work: https://npm.work.example.com/
```
- Then work:@corp/lib@^2.0.0 resolves against https://npm.work.example.com/, and the built-in gh alias can be redirected to a GHES host.
- Env-var substitution (${VAR}) is supported in namedRegistries values, mirroring the .npmrc convention.
- Reserved alias names (npm, jsr, github, workspace, catalog, file, git, http, https, link, patch, and related git host shorthands) cannot be redefined as user-named registries - the resolver throws ERR_PNPM_RESERVED_NAMED_REGISTRY_ALIAS at startup rather than silently shadowing another protocol. Malformed URLs throw ERR_PNPM_INVALID_NAMED_REGISTRY_URL at startup too, instead of failing as a confusing 404 during resolution.
- On publish, createExportableManifest strips any named-registry prefix (both the built-in gh: and any user-configured alias) so npm and yarn consumers can still resolve the dependency via their own scope-registry configuration - mirroring the user-facing requirement when installing such a dep without the prefix.
The prefix is gh: rather than github: because github: is reserved by npm-package-arg / hosted-git-info as a git host shorthand (e.g. github:owner/repo) - reusing it would be a deviation from the specs used by the npm CLI. gh: is shorter, matches vlt's convention, and cannot collide with any existing npm scheme.
Unlike jsr:, gh: (and any other named-registry alias) does not rewrite the package name - gh:@acme/foo resolves @acme/foo from the GitHub Packages registry as-is. This also means npm/yarn consumers see the original name after the prefix is stripped on publish.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* chore: upgrade @typescript/native-preview to 7.0.0-dev.20260421.2
- Add explicit `types: ["node"]` to the shared tsconfig because tsgo
20260421 no longer auto-acquires `@types/*` from `node_modules`.
- Refactor test files to explicitly import jest globals (`describe`,
`it`, `test`, `expect`, `beforeEach`, etc.) from `@jest/globals`
instead of relying on `@types/jest` ambient declarations. Under the
new tsgo build, `import { jest } from '@jest/globals'` shadows the
ambient `jest` namespace, breaking `@types/jest`'s `declare var
describe: jest.Describe;` globals.
- Add `@jest/globals` to each package's devDependencies where tests
now import from it, and add `@types/node` to packages that need it
but were relying on hoisted resolution.
- Replace `fail()` calls with `throw new Error(...)` since `fail` is
no longer globally available.
* chore: fix remaining tsgo type-strictness errors
- Strip `as <PnpmType>` casts on objects passed to toMatchObject /
toStrictEqual / toEqual; @jest/globals rejects the typed objects
(which include AsymmetricMatchers) vs. the repo-specific type.
- Type `jest.fn<...>()` explicitly where the mock's signature matters
for toHaveBeenCalledWith.
- Replace `beforeEach(() => X)` with `beforeEach(() => { X })` so the
return value is void, as the stricter jest typing requires.
- Use `expect.objectContaining({...})` in one place where the full
expected object triggered stricter type resolution.
- Cast `prompt.mock.calls` arg through `as unknown as Record<...>[]`
for patch.test.ts's nested-array matchers.
- Fix off-by-one `<reference path>` in pnpm/test/getConfig.test.ts
that only surfaced now.
- Move `@jest/globals` from devDependencies to dependencies in the
two `__utils__` packages that import it from `src/`.
- Clean up unused imports from the @jest/globals migration.
* chore: address Copilot review on #11332
- Move misplaced `@jest/globals` imports to the top import block in
checkEngine, run.ts, and workspace/root-finder tests where the
script dropped them below executable code.
- Replace `try { await x(); throw new Error('should have thrown') } catch`
in bins/linker, lockfile/fs, and resolving/local-resolver tests with
`await expect(x()).rejects.toMatchObject({...})`. The old pattern
swallowed an unrelated `throw` if the under-test call silently
succeeded, which would fail on the catch-block assertion with a
misleading message.
* chore: upgrade @typescript/native-preview to 7.0.0-dev.20260421.2
- Add explicit `types: ["node"]` to the shared tsconfig because tsgo
20260421 no longer auto-acquires `@types/*` from `node_modules`.
- Refactor test files to explicitly import jest globals (`describe`,
`it`, `test`, `expect`, `beforeEach`, etc.) from `@jest/globals`
instead of relying on `@types/jest` ambient declarations. Under the
new tsgo build, `import { jest } from '@jest/globals'` shadows the
ambient `jest` namespace, breaking `@types/jest`'s `declare var
describe: jest.Describe;` globals.
- Add `@jest/globals` to each package's devDependencies where tests
now import from it, and add `@types/node` to packages that need it
but were relying on hoisted resolution.
- Replace `fail()` calls with `throw new Error(...)` since `fail` is
no longer globally available.
* chore: fix remaining tsgo type-strictness errors
- Strip `as <PnpmType>` casts on objects passed to toMatchObject /
toStrictEqual / toEqual; @jest/globals rejects the typed objects
(which include AsymmetricMatchers) vs. the repo-specific type.
- Type `jest.fn<...>()` explicitly where the mock's signature matters
for toHaveBeenCalledWith.
- Replace `beforeEach(() => X)` with `beforeEach(() => { X })` so the
return value is void, as the stricter jest typing requires.
- Use `expect.objectContaining({...})` in one place where the full
expected object triggered stricter type resolution.
- Cast `prompt.mock.calls` arg through `as unknown as Record<...>[]`
for patch.test.ts's nested-array matchers.
- Fix off-by-one `<reference path>` in pnpm/test/getConfig.test.ts
that only surfaced now.
- Move `@jest/globals` from devDependencies to dependencies in the
two `__utils__` packages that import it from `src/`.
- Clean up unused imports from the @jest/globals migration.
* chore: address Copilot review on #11332
- Move misplaced `@jest/globals` imports to the top import block in
checkEngine, run.ts, and workspace/root-finder tests where the
script dropped them below executable code.
- Replace `try { await x(); throw new Error('should have thrown') } catch`
in bins/linker, lockfile/fs, and resolving/local-resolver tests with
`await expect(x()).rejects.toMatchObject({...})`. The old pattern
swallowed an unrelated `throw` if the under-test call silently
succeeded, which would fail on the catch-block assertion with a
misleading message.
Library packages had `prepublishOnly: pn compile`, which expands to
`tsgo --build && pn lint --fix`. During `pn release` that runs eslint
against ~150 packages for no benefit — the code has already been linted
in CI and the release flow's upfront compile has already built dist/.
Switch lib prepublishOnly to a bare `tsgo --build` so the safety-net
compile stays but the per-package eslint cost is gone.
* fix: give each runtime variant its own global virtual store entry
When a runtime package (e.g. node@runtime:X.Y.Z) uses a variations
resolution, createFullPkgId() in @pnpm/deps.graph-hasher was hashing
the whole VariationsResolution — the same hash on every host — so the
global virtual store path collided between variants. Whichever variant
installed first won, and a later `pnpm add --libc=musl node@runtime:<v>`
silently reused the cached glibc (or macOS/Windows) binary.
The fix threads supportedArchitectures down to createFullPkgId so the
selected variant's integrity is used as the package fingerprint. Two
related cleanups land with it:
- Extract the platform-variant selection logic to @pnpm/resolving.resolver-base
as selectPlatformVariant/resolvePlatformSelector. The helper's libc
match also required a fix: a variant with no libc is the "default"
build, and a request for a non-default libc (e.g. musl) must require
an exact match so the default variant doesn't silently win.
- @pnpm/installing.package-requester's findResolution now delegates to
the shared helper, and the new supportedArchitectures param is plumbed
through calcDepState / calcGraphNodeHash / iterateHashedGraphNodes /
lockfileToDepGraph and their callers in deps-resolver, deps-restorer,
deps-installer, graph-builder, and building.after-install.
* feat: add pnpm build-sea command for building Node.js SEA executables
Adds `pnpm build-sea` under @pnpm/releasing.commands. Takes a CommonJS
entry file and a set of target triplets (linux-x64, linux-x64-musl,
linux-arm64, linux-arm64-musl, macos-x64, macos-arm64, win-x64,
win-arm64) and produces a standalone executable per target under
dist-sea/<target>/.
Each target's Node.js runtime is fetched via `pnpm add node@runtime:<v>
--os=<os> --cpu=<arch> --libc=<libc>` into $PNPM_HOME/build-sea/<target>-<v>/
so binaries are hardlinked from the global content-addressable store and
`pnpm store prune` can reclaim them.
Requires Node.js v25.5+ to perform the --build-sea injection. If the
running Node is older, a v25 binary is downloaded and used as the builder
automatically. macOS outputs are ad-hoc signed with codesign (on macOS)
or ldid (when cross-compiling from Linux), which is required because SEA
injection invalidates the binary's existing signature.
* fix(build-sea): reject malformed --target, --output-name and use mkdtemp for config
Addresses Copilot review feedback on the build-sea command:
- parseTarget() previously destructured the target string, silently
accepting extra `-` segments. Inputs like `linux-x64-musl-../../outside`
would pass validation and flow into path.join. Validation is now done
with a strict anchored regex.
- --output-name was passed into path.join() without sanitization, so a
caller could escape the output directory with path separators or `..`.
validateOutputName() now rejects anything that isn't a plain basename.
- The per-target SEA config file was written to a predictable path under
os.tmpdir() (derived from the target name and Date.now()), which is
unsafe on multi-user systems. It now lives inside a fresh mkdtemp()
directory and is opened with the exclusive "wx" flag.
- New test cases cover extra-segment targets, uppercase/whitespace
variants, and the full matrix of invalid --output-name inputs.
* rename: build-sea → pack-app
`build-sea` required knowing what a SEA is. `pack-app` is self-describing,
doesn't collide with pnpm's existing `bin` concept, and parallels the
existing `pack` command.
- Command name: build-sea → pack-app
- Default output dir: dist-sea → dist-app
- Error codes: PACK_APP_* (was BUILD_SEA_*)
- Export/type: packApp / PackAppOptions (was buildSea / BuildSeaOptions)
- Install cache dir: $PNPM_HOME/pack-app (was $PNPM_HOME/build-sea)
The Node.js `--build-sea` flag name itself is unchanged — that's a
Node.js feature and outside this project's naming.
* fix(pack-app): reject directory entries, pin builder to >=25.5, refuse macOS target on Windows
Addresses Copilot review feedback on the pack-app command:
- entry validation now rejects non-file paths (directories, symlinks to
non-files) with a dedicated PACK_APP_ENTRY_NOT_FILE instead of
surfacing a less actionable error later in the SEA build.
- DEFAULT_BUILDER_SPEC was the bare major ("25"), which would satisfy
with 25.0.x if that version is still present — those point releases
predate --build-sea support. Tightened to ">=25.5.0 <26.0.0" so the
download is guaranteed to support the flag without ever crossing a
major.
- adHocSignMacBinary() silently skipped re-signing on Windows hosts.
Now throws PACK_APP_MACOS_SIGN_UNSUPPORTED_HOST with a hint to build
the target on macOS/Linux or re-sign manually.
- resolvePlatformSelector() JSDoc now matches what the code actually
does (picks the first entry when it is not "current"; later entries
are ignored).
- New test case covers the directory-as-entry rejection.
* refactor(pack-app): switch target OS names to process.platform constants
Previously `pack-app` accepted `macos-*` / `win-*` as the OS portion of a
target triplet and translated them to `darwin` / `win32` internally. The
translation layer made the CLI surface inconsistent with the values that
`pnpm add --os=…` and `supportedArchitectures.os` already use, and added
a small footgun (e.g. users setting `supportedArchitectures: { os: [darwin] }`
but typing `macos-arm64` for pack-app).
The supported target OS set is now `linux | darwin | win32`, matching
`process.platform`. Old inputs like `macos-arm64` or `win-x64` now fail
validation with a clear error pointing to the new naming. The internal
parseTarget helper drops its TARGET_OS_MAP lookup entirely.
This is a change to an unreleased command so there is no back-compat
concern. pnpm's own artifact directory names (`pnpm/artifacts/macos-*/`,
`pnpm/artifacts/win-*/`) are an internal implementation detail and are
not affected by this change.
* feat(pack-app): read defaults from pnpm.app in package.json
Every pack-app flag (--entry, --target, --node-version, --output-dir,
--output-name) can now be preconfigured in the project's package.json
under a new "pnpm.app" object:
{
"name": "my-cli",
"pnpm": {
"app": {
"entry": "dist/index.cjs",
"targets": ["linux-x64", "darwin-arm64", "win32-x64"],
"nodeVersion": "25",
"outputDir": "release",
"outputName": "my-cli"
}
}
}
CLI flags always win. --target replaces the configured list rather than
appending, so a user can narrow the default set at the command line.
The config loader is strict: unknown keys under pnpm.app and any
type-mismatched values throw PACK_APP_INVALID_CONFIG so mistakes surface
at invocation time instead of silently being ignored.
Chose pnpm.app over pnpm.packApp because it's the shorter, cleaner
namespace for anything related to the app bundle (future sibling
commands like run-app / deploy-app could share the same object without
a naming clash). Chose package.json over pnpm-workspace.yaml because
the config is inherently per-project, whereas pnpm-workspace.yaml is
workspace-root-only.
* fix(pack-app): deterministic libc selection and stricter output-name validation
Addresses Copilot review feedback:
- ensureNodeRuntime() now always passes an explicit --libc for linux
targets. Without a suffix, linux-x64 and linux-arm64 default to
--libc=glibc instead of letting the user's supportedArchitectures.libc
config or the host's detected libc decide the variant. The install
cache directory mirrors this, so glibc and musl variants are always
distinct (linux-x64-glibc vs linux-x64-musl).
- resolveBuilderBinary() now pins the host libc when downloading a
builder Node on Linux. A user whose config sets supportedArchitectures.libc
to musl no longer ends up with a musl Node that the glibc host cannot
execute.
- validateOutputName() rejects Windows-invalid filename characters
(<>:"|?* and NUL), Windows reserved device names (CON, NUL, COM1, etc.),
and names ending in a dot or space — problems surface at invocation
time rather than during writeFile(outputFile, ...) on Windows.
- lockfileToDepGraph variants tests no longer derive the "host"
variant from process.platform/process.arch; they always pass an
explicit supportedArchitectures selector so the expectations hold on
any CI host (including Alpine/musl).
* chore: add "toctou" to cspell wordlist
`TOCTOU` (time-of-check-to-time-of-use) is the standard term for the
race-condition class the pack-app SEA-config comment describes. Adding
it to the wordlist unblocks the Lint CI step.
* fix: lint
## Summary
Adds an opt-in **pnpm agent** server that resolves dependencies server-side and streams only the files missing from the client's content-addressable store.
- **`@pnpm/agent.server`** — multi-process HTTP server (Node.js `cluster`) with SQLite-backed metadata and file caches
- **`@pnpm/agent.client`** — streams an NDJSON response, dispatches worker threads to fetch files while the server is still resolving
- **New config**: `agent` in `pnpm-workspace.yaml` (opt-in)
## How it works
1. Client reads integrity hashes from its local store index
2. Sends `POST /v1/install` with dependencies + store integrities
3. Server resolves the dependency tree using pnpm's `install({ lockfileOnly: true })`, with a SQLite-backed `PackageMetaCache` for fast repeat resolution
4. As each package resolves, a wrapped `storeController.requestPackage` looks up its files and immediately streams digests the client is missing (NDJSON `D` lines)
5. Client reads the stream line by line; digest batches fill up and dispatch worker threads to `POST /v1/files` — file downloads overlap with server-side resolution
6. After resolution, server sends index entries (`I` lines) and lockfile (`L` line)
7. Client writes index entries to store, then runs headless install with a wrapped `fetchPackage` that calls `readPkgFromCafs` with `verifyStoreIntegrity: false` (files are trusted from the agent)
8. `/v1/files` response is gzip-streamed (274MB → ~80MB) — server pipes through `createGzip`, worker pipes through `createGunzip`, parsing and writing files to CAFS as data arrives
## Performance
1351-package project, cold local store, warm server (localhost):
| Scenario | Time |
|----------|------|
| Vanilla pnpm install (cold OS cache) | ~48s |
| Vanilla pnpm install (warm OS cache) | ~34s |
| With pnpm agent (consistent) | **~33s** |
### Key optimizations
1. **SQLite metadata cache** — server-side resolution drops from ~3.4s to ~0.9s
2. **SQLite file store** — consistent read performance regardless of OS file cache state
3. **Streaming `/v1/install`** — file digests stream during resolution, downloads start before resolution finishes
4. **Gzip-streamed `/v1/files`** — whole-stream gzip (274MB → ~80MB), significant savings on remote servers
5. **Worker-thread streaming HTTP** — workers pipe gzip → parse → write to CAFS as data arrives, no buffering
6. **No rehashing** — server-provided digests used directly, skipping 33K SHA-512 computations
7. **No re-verification** — wrapped `fetchPackage` calls `readPkgFromCafs` with `verifyStoreIntegrity: false`
8. **Direct `writeFileSync` with `wx`** — no stat + temp + rename
9. **Pre-packed msgpack** — server sends raw store index buffers, client writes directly to SQLite
10. **WAL checkpoint** — ensures store index entries written by agent are visible to headless install's worker threads
## Usage
Start the server:
```bash
node agent/server/lib/bin.js
```
Configure in `pnpm-workspace.yaml`:
```yaml
agent: http://localhost:4873
```
Skips the minimumReleaseAge maturity check when the registry metadata
lacks the "time" field, instead of throwing ERR_PNPM_MISSING_TIME.
Defaults to true, and prints a warning once per affected package.
* test: ensure prerelease weighting is correct
* fix: use higher weight for package versions already in lockfile
* test: remove fundamentally incompatible test
* fix(test): use undici MockAgent instead of nock for HTTP mocking
nock only patches Node's built-in http/https modules, but pnpm uses
undici for HTTP requests. Replace nock with @pnpm/testing.mock-agent
(which wraps undici's MockAgent) so the regression test actually
intercepts registry metadata requests.
* fix(benchmarks): show errors from store populate step
The populate step redirected both stdout and stderr to /dev/null,
hiding the actual error when pnpm install fails during benchmarks.
* fix(benchmarks): replace deprecated packages in benchmark fixture
The old fixture used deprecated babel 6, gulp, and other legacy
packages whose transitive dependencies (e.g. es-abstract) are missing
the "time" field in registry metadata, causing ERR_PNPM_MISSING_TIME
with time-based resolution mode.
Replace with modern equivalents (babel 7, webpack 5, MUI, Redux
Toolkit, etc.) that maintain a similar dependency tree size (~1300
packages) while using well-maintained packages with proper registry
metadata.
* fix(benchmarks): drop eslint plugins that pull in es-abstract
eslint-plugin-react, eslint-plugin-import, and eslint-plugin-jsx-a11y
transitively depend on es-abstract, whose registry metadata lacks the
"time" field. Replace them with eslint-plugin-prettier to avoid
ERR_PNPM_MISSING_TIME with time-based resolution.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>