## What
Adds an opt-in `frozenStore` / `--frozen-store` setting (default `false`) that lets `pnpm install --offline --frozen-lockfile` run against a package store that lives on a **read-only filesystem** — a Nix store, a read-only bind mount, an OCI layer.
## Why
A normal install fails against such a store **not** because it writes package content, but because it unconditionally:
1. opens the SQLite `index.db` in WAL mode, which needs to create `-shm`/`-wal` sidecars in the store directory; and
2. writes a project-registry entry under the store.
Both fail with `attempt to write a readonly database` / `EROFS` on a read-only store directory, even when the store is complete and the lockfile is frozen. This blocks any deployment that wants an immutable, content-addressed store.
## How
When `frozenStore` is enabled, pnpm opens `index.db` through the SQLite **`immutable=1`** URI — which tells SQLite the file cannot change underneath it, so it bypasses the WAL/`-shm` sidecar machinery entirely and reads the raw file with zero sidecar creation — and suppresses every store-write path. Pair it with `--offline --frozen-lockfile` against a fully-populated store. It is incompatible with two settings that would write into the store, and each throws a clear config-conflict error before any network or store access: **`--force`** (which bypasses the no-write-on-hit skip → `ERR_PNPM_CONFIG_CONFLICT_FROZEN_STORE_WITH_FORCE`) and a configured **pnpr server** (which would fetch and write packages into the store → `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`).
> Plain `SQLITE_OPEN_READ_ONLY` is **not** sufficient: opening a WAL-mode db read-only still tries to create the `-shm` sidecar, which fails on a read-only *directory*. `immutable=1` is the load-bearing piece.
> **Node.js requirement:** `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite (so the `immutable=1` query is honored rather than treated as part of a literal filename) starting in **v22.15.0** (22.x line), **v23.11.0**, and every **v24+**. pnpm's `engines` floor is `>=22.13`, so on a runtime older than that the frozen open is detected up front and fails with a clear `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` instead of SQLite's cryptic "unable to open database file". (pacquet uses rusqlite with an explicit `SQLITE_OPEN_URI` flag, so it has no such floor.)
> The `immutable=1` URI path is also percent-encoded (`%`→`%25`, `?`→`%3f`, `#`→`%23`, in that order, leaving `/` literal) so a store path containing those characters doesn't truncate the path or inject a spurious query parameter — applied identically in both stacks.
### Build backstop under the global virtual store
Under the global virtual store (default), a package's directory lives **inside** the store (`{storeDir}/links/...`). Applying a patch or running an allowlisted lifecycle script writes into that directory — so on a frozen store it would crash mid-build with a raw `EROFS`. A fully-seeded store never reaches the build step (patched/built packages are imported from the side-effects cache and filtered out by the `isBuilt` gate), so any residual build candidate means the seed is **missing that package's build output**.
`buildModules` now refuses up front with `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` and actionable guidance ("rebuild the seed with their scripts enabled, or remove them from `onlyBuiltDependencies`") instead of failing cryptically once a script starts. The check is gated on the global virtual store — under the isolated linker, slot directories live in the writable project-local store, so builds there are fine. Non-allowlisted scripts never run, so they are not treated as a blocking write.
Bin-linking has its own read-only-store edge under the global virtual store. On a **warm** checkout (the project's `.bin/<name>` already points at the seed target) `linkBin` returns before touching the store, so it is write-free. But on a **fresh** checkout it (re)creates the bin and calls `fixBin`, whose `chmod` targets the bin's **source file inside the store** (`{storeDir}/links/...`) — which is refused with `EPERM`/`EACCES` on a read-only store, even though a complete seed already ships that bin executable (so the `chmod` is redundant). `@pnpm/bins.linker` now wraps that call in `ensureExecutable`: it swallows the refusal when the target is already executable and rethrows otherwise, so bin-linking is write-free against a frozen store on a cold checkout too, while a genuinely non-executable bin (a broken seed) still surfaces as an error.
The blocking predicate distinguishes the two write kinds: a **patch** is applied regardless of `ignoreScripts`, so a patched package is always blocked; a **lifecycle script** is suppressed under `ignoreScripts`, so an allowlisted build-requiring package is *not* blocked when scripts are off (it would write nothing). This avoids falsely rejecting a valid `--ignore-scripts` frozen install. **Optional dependencies are exempt**: a build or patch failure on an optional dependency is non-fatal at runtime, so a seed missing an optional package's build output skips that build (emitting the `skipped-optional-dependency` log) instead of blocking the install — in both stacks.
### Both stacks (parity rule)
**TypeScript pnpm CLI**
- Config plumbing (`@pnpm/config.reader`): `frozen-store` type, config-file key, default, `Config.frozenStore`.
- The read-only open branch (`@pnpm/store.index`): `immutable=1`, read-only statements, throwing mutators.
- Wiring through `@pnpm/store.controller` and `@pnpm/store.connection-manager` to the sole `StoreIndex` construction site.
- Gating the project-registry write (`@pnpm/installing.context`).
- The `--force` / pnpr-server conflict guards (`@pnpm/installing.deps-installer`) and CLI surface (`@pnpm/installing.commands`).
- **After-install rebuild** (`@pnpm/building.after-install`): the post-install rebuild opens its `StoreIndex` immutably under the flag, so re-reading the store for a rebuild never attempts a writable open against the frozen store.
- **Worker fix:** `@pnpm/worker` opens its *own* writable `StoreIndex` on every `readPkgFromCafs` cache hit, so a pure read crashed on a frozen store. `frozenStore` is threaded through to `getStoreIndex` and keyed into its connection cache.
- **Build backstop** (`@pnpm/building.during-install`): `buildModules` throws `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` for a GVS slot that would build/patch on a frozen store, honoring `ignoreScripts`; threaded from `@pnpm/installing.deps-installer`.
- **Bin-linking on a read-only store** (`@pnpm/bins.linker`): `linkBin` wraps the `fixBin` chmod in `ensureExecutable`, which tolerates `EPERM`/`EACCES` when the bin's store-resident source is already executable (a complete seed) and rethrows otherwise — so a fresh checkout against a frozen store links bins without crashing on the redundant chmod. Catch-on-failure keeps the writable hot path at zero added syscalls.
**Rust pacquet**
- A dedicated `open_immutable` / `shared_immutable_in` opens via `immutable=1`, selected only under the flag. Plain `open_readonly` keeps the ordinary `SQLITE_OPEN_READ_ONLY` open (WAL locking intact) because normal installs read the index while the same process's `StoreIndexWriter` writes it concurrently — an immutable connection skips all locking and change detection, so a concurrent writer would make those reads undefined.
- `--frozen-store` CLI flag + `frozenStore` workspace-yaml setting.
- The store-index writer is replaced with a drain-and-drop stub (`spawn_disabled`) and `init_store_dir_best_effort` is skipped under the flag.
- **Build backstop:** `build_modules` returns `BuildModulesError::FrozenStoreNeedsBuild` (`ERR_PNPM_FROZEN_STORE_NEEDS_BUILD`) under the same GVS + frozen-store condition, threaded from `config.frozen_store`. The gate keys off `should_run_scripts` (which already folds the allow-build policy), so it is correct without an explicit ignore-scripts branch — pacquet has no configurable ignore-scripts mode yet.
pacquet already separated read-only index access (`shared_readonly_in`, or `shared_immutable_in` under the flag) from writes, so it never had the worker-conflation bug; the flag makes the "no writes attempted" contract explicit and gates the remaining best-effort write attempts.
## Testing
- **TS:** `store.index` frozen-mode-on-`0555`-directory test (reads work, writes throw `ERR_PNPM_FROZEN_STORE_WRITE`) plus a path-with-`?` open test — both gated on the runtime's immutable-URI support, with a complementary test asserting `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` fires where that support is absent (the CI Node 22.13.0 path); `config.reader` round-trip; `deps-installer` `--force` and pnpr-server conflict guards; `worker`/`package-requester` unit tests. End-to-end on a `chmod -R 0555` store: install succeeds, `node_modules` materializes, no `-shm`/`-wal`/`-journal` sidecars; negative control without the flag fails as expected; incomplete store → clean offline error.
- **pacquet:** `open_immutable_reads_wal_db_on_readonly_directory` unit test plus the `immutable_sqlite_uri` encoding test and a path-with-`?` open test; yaml + CLI fold tests; **integration test** `frozen_store_installs_against_a_read_only_store` — primes a store, `chmod 0555` the tree, runs `install --frozen-lockfile --frozen-store --offline`, asserts success + materialized `node_modules` + zero sidecars. Confirmed load-bearing by reverting the `immutable=1` fix (test then fails).
- **Build backstop (both stacks):** `building/during-install` unit tests — approved-build-not-cached and patched-not-cached refuse; cached, non-allowlisted, and non-GVS cases pass through; **approved-build-under-`ignoreScripts` passes through while patched-under-`ignoreScripts` still refuses** — and the matching pacquet `build_modules` tests (`frozen_store_gvs_patch_not_seeded_refuses` + GVS-off / frozen-off controls). Each confirmed load-bearing by disabling the relevant guard and watching the corresponding test fail.
- **Bin-linking (`bins/linker`):** `ensureExecutable` tests with `fixBin` mocked to reject with `EPERM` — an already-executable bin source resolves (and `fixBin` is asserted called, so it isn't the warm skip-guard passing), a non-executable one rethrows `EPERM`. Confirmed end-to-end by running the built `linkBins` against a real `chflags uchg`-immutable store: the executable-seed case resolves and links the bin, the non-executable-seed control throws `EPERM`.
A changeset is included with `"pnpm": minor` and `"@pnpm/bins.linker": patch` (the read-only-store bin-linking fix).
## 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.
* 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.
* 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`.
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.
`pnpm install` (non-frozen) used to react to `ERR_PNPM_TARBALL_INTEGRITY` by logging the error, silently re-resolving from the registry, and overwriting the locked integrity. The lockfile's integrity was effectively advisory by default — a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed `pnpm-lock.yaml`.
Integrity mismatches against the lockfile now fail by default.
The **only** opt-in is **`pnpm install --update-checksums`** — a new flag, narrowly scoped to refreshing the locked integrity values. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the rewrite stays auditable.
`--force` and `pnpm update` deliberately do **not** bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. `--frozen-lockfile` behavior is unchanged. `--fix-lockfile` keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass. Combining `--frozen-lockfile` with `--update-checksums` errors out — frozen mode refuses to rewrite the lockfile, which is exactly what `--update-checksums` is for.
`--update-checksums` also bypasses the resolver's on-disk metadata cache fast path (`pickPackage.ts:271`, `pick_package.rs:531`). Without that, a stale on-disk packument that already contained the pinned version would short-circuit the registry entirely and the flag would silently no-op on dev machines. With the gate, every first-encounter goes through a conditional GET; the in-memory cache is left alone so second-and-onward references within the same install still hit cached fresh data (one network round-trip per *unique* package, not per reference).
## Reported by
Reported privately via the security channel. The reproduction:
1. Publish `example-package@1.0.0` with content `v1` and install with pnpm; lockfile records the `v1` integrity.
2. Replace the registry's tarball+metadata for the same `1.0.0` with content `v2`.
3. On a clean store/cache, run `pnpm install`. Before this fix, pnpm logged `ERR_PNPM_TARBALL_INTEGRITY` but exited 0 with `v2` installed and the lockfile rewritten to the new integrity. After this fix, the same install exits non-zero.
## Prior art
- **npm** ([sebhastian](https://sebhastian.com/npm-err-code-eintegrity/)): hard-fails with `EINTEGRITY`. No dedicated override flag — recovery is `npm cache clean --force`, manually editing the lockfile, or deleting it.
- **yarn** ([Sean C Davis](https://www.seancdavis.com/posts/fix-yarn-integrity-check-failed/)): hard-fails with "Integrity check failed". Has a dedicated **`yarn install --update-checksums`** flag — pnpm now adopts the same name.
## Pacquet parity
Pacquet was already fail-hard on integrity mismatch by default (no auto-repair path to remove). This PR brings the rest of the surface into line so `pnpm install --update-checksums` keeps working when pacquet is the materialization target, and `pacquet install --update-checksums` behaves identically standalone:
- New `--update-checksums` flag on `pacquet install` (`crates/cli/src/cli_args/install.rs`), plumbed through `Install` and `InstallWithFreshLockfile` into the resolver.
- When the flag is set, pacquet skips the frozen-lockfile fast path and routes through the fresh-resolve path so locked integrity values get rewritten from the registry.
- `--frozen-lockfile + --update-checksums` errors with `pacquet_package_manager::frozen_lockfile_with_outdated_lockfile`, mirroring pnpm's `ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE`.
- `pacquet_tarball::verify_checksum_error` now carries a help hint pointing at `--update-checksums` and calling out the supply-chain implication, matching the updated pnpm `TarballIntegrityError`.
- The disk fast-path gate is mirrored in `crates/resolving-npm-resolver/src/pick_package.rs:531`, with the flag threaded from `ResolveOptions` → `PickPackageOptions`.
## Summary
- pnpm installing a Node.js runtime (`node@runtime:<ver>`, `pnpm env use`, `pnpm runtime set node`) no longer extracts the bundled `npm`, `npx`, and `corepack`. These make up ~2,800 of ~5,800 files in a typical Node.js archive, so skipping them materially reduces hashing, CAS writes, SQLite index inserts, and import/link work.
- Users who still need `npm` can install it as a separate package.
## How
A new optional `ignoreFilePattern` (regex source string, serializable across the worker boundary) threads through `FetchOptions` → `tarball-fetcher` → `@pnpm/worker` → `cafs.addFilesFromTarball`. `cafs.addFilesFromTarball` now accepts a per-call ignore on top of the existing cafs-level `ignoreFile`; the two are combined.
`@pnpm/fetching.binary-fetcher` defines the Node-specific regex and applies it when `opts.pkg.name === 'node'`:
- Tarball path: sets `ignoreFilePattern`.
- Windows zip path: new `ignoreEntry?: RegExp` on `AssetInfo`; `extractZipToTarget` strips the `basename/` prefix and skips matching entries before `zip.extractEntryTo`.
`@pnpm/engine.runtime.node-resolver`'s `getNodeBinsForCurrentOS` drops `npm`/`npx` so pnpm no longer creates shims for bins that no longer exist.
## Breaking change
Shipping in v11. After this lands, `pnpm runtime set node` / `node@runtime:<version>` no longer puts `npm`, `npx`, or `corepack` on `$PATH`. Scripts that call them directly will need to install npm separately.
## Summary
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
```
The worker's initStore eagerly opened the SQLite index database, racing
with the main thread's StoreIndex constructor. On Windows with mandatory
file locking this caused SQLITE_CANTOPEN. The worker now initializes its
StoreIndex lazily on first use. Also fixed bare .catch() creating an
unhandled promise rejection.
## Problem
The indexed package importer always creates a staging temp directory, imports files there, then renames to the final location. For cold installs where the target doesn't exist (the common case), the staging + rename is unnecessary overhead.
## Solution
- **Fast path**: callers already verify the target package is missing before calling `importIndexedDir`, so we can write directly into the final directory and skip the temp dir + rename. Falls back to the atomic staging path on EEXIST (concurrent import race) or when `keepModulesDir` is set (hoisted linker needs to merge existing `node_modules`).
- **Completion marker**: `package.json` is written last by `tryImportIndexedDir`, so `pkgExistsAtTargetDir()` (which checks for `package.json`) won't consider a partially-imported directory as complete after a crash.
- **Atomic copy**: the copy import path (non-COW filesystems) uses a temp file + `renameOverwriteSync` for the `package.json` write, since `copyFileSync` is not atomic. Hard links and reflinks are inherently atomic. This is expressed via the `Importer` interface (`importFile` + `importFileAtomic`), passed as the first argument to `importIndexedDir`.
- **Synthetic package.json**: packages that lack a `package.json` (e.g. injected Bit workspace packages) now get a synthetic empty `{}` added to the store, so the completion marker works universally.
- **DRY**: extracted `retryWithSanitizedFilenames()` to deduplicate the ENOENT handler used by both the fast path and staging path.
* chore: update all dependencies to latest versions
Update all outdated dependencies across the monorepo catalog and fix
breaking changes from major version bumps.
Notable updates:
- ESLint 9 → 10 (fix custom rule API, disable new no-useless-assignment)
- @stylistic/eslint-plugin 4 → 5 (auto-fixed indent changes)
- @cyclonedx/cyclonedx-library 9 → 10 (adapt to removed SPDX API)
- esbuild 0.25 → 0.27
- TypeScript 5.9.2 → 5.9.3
- Various @types packages, test utilities, and build tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update unified/remark/mdast imports for v11/v4 API changes
Update imports in get-release-text for the new ESM named exports:
- mdast-util-to-string: default → { toString }
- unified: default → { unified }
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve typecheck errors from dependency updates
- isexe v4: use named import { sync } instead of default export
- remark-parse/remark-stringify v11: add vfile as packageExtension
dependency so TypeScript can resolve type declarations
- get-release-text: remove unused @ts-expect-error directives
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert runtime dependency major version bumps
Revert major version bumps for runtime dependencies that are bundled
into pnpm to fix test failures where pnpm add silently fails:
- bin-links: keep ^5.0.0 (was ^6.0.0)
- cli-truncate: keep ^4.0.0 (was ^5.2.0)
- delay: keep ^6.0.0 (was ^7.0.0)
- filenamify: keep ^6.0.0 (was ^7.0.1)
- find-up: keep ^7.0.0 (was ^8.0.0)
- isexe: keep 2.0.0 (was 4.0.0)
- normalize-newline: keep 4.1.0 (was 5.0.0)
- p-queue: keep ^8.1.0 (was ^9.1.0)
- ps-list: keep ^8.1.1 (was ^9.0.0)
- string-length: keep ^6.0.0 (was ^7.0.1)
- symlink-dir: keep ^7.0.0 (was ^9.0.0)
- terminal-link: keep ^4.0.0 (was ^5.0.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore runtime dependency major version bumps
Re-apply all runtime dependency major version bumps that were
previously reverted. All packages maintain their default exports
except isexe v4 which needs named imports.
Updated runtime deps:
- bin-links: ^5.0.0 → ^6.0.0
- cli-truncate: ^4.0.0 → ^5.2.0
- delay: ^6.0.0 → ^7.0.0
- filenamify: ^6.0.0 → ^7.0.1
- find-up: ^7.0.0 → ^8.0.0
- isexe: 2.0.0 → 4.0.0 (fix: use named import { sync })
- normalize-newline: 4.1.0 → 5.0.0
- p-queue: ^8.1.0 → ^9.1.0
- ps-list: ^8.1.1 → ^9.0.0
- string-length: ^6.0.0 → ^7.0.1
- symlink-dir: ^7.0.0 → ^9.0.0
- terminal-link: ^4.0.0 → ^5.0.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert tempy to 3.0.0 to fix bundle hang
tempy 3.2.0 pulls in temp-dir 3.0.0 which uses async fs.realpath()
inside its module init. When bundled by esbuild into the __esm lazy
init pattern, this causes a deadlock during module initialization,
making the pnpm binary hang silently on startup.
Keeping tempy at 3.0.0 which uses temp-dir 2.x (sync fs.realpathSync).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add comment explaining why tempy cannot be upgraded
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert nock to 13.3.4 for node-fetch compatibility
nock 14 changed its HTTP interception mechanism in a way that doesn't
properly intercept node-fetch requests, causing audit tests to hang
waiting for responses that are never intercepted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add comment explaining why nock cannot be upgraded
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update symlink-dir imports for v10 ESM named exports
symlink-dir v10 removed the default export and switched to named
exports: { symlinkDir, symlinkDirSync }.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert @typescript/native-preview to working version
Newer tsgo dev builds (>= 20260318) have a regression where
@types/node cannot be resolved, breaking all node built-in types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: vulnerabilities
* fix: align comment indentation in runLifecycleHook
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: pin msgpackr to 1.11.8 for TypeScript 5.9 compatibility
msgpackr 1.11.9 has broken type definitions that use Iterable/Iterator
without required type arguments, causing compile errors with TS 5.9.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add n/prefer-node-protocol rule and autofix all bare builtin imports
to use the node: prefix. Simplify the simple-import-sort builtins
pattern to just ^node: since all imports now use the prefix.
Add eslint-plugin-simple-import-sort to enforce consistent import ordering:
- Node.js builtins first
- External packages second
- Relative imports last
- Named imports sorted alphabetically within each statement
## Summary
Fixes intermittent failures in the `parallel dlx calls of the same package` test, especially on Windows CI. Multiple race conditions were discovered when concurrent `pnpm dlx` processes share the same Global Virtual Store (GVS):
- **Content-verified skip in GVS mode**: When `safeToSkip` is true and a rename fails because the target already exists (ENOTEMPTY/EEXIST/EPERM), verify all files match (inode or content comparison) before skipping. Falls through to `renameOverwriteSync` if content doesn't match.
- **Tolerate EPERM during bin creation on Windows**: `cmd-shim`'s `chmod` can fail with EPERM when another process holds the `.bin` file. Warn instead of crashing.
- **Handle EPERM in DLX cache symlink**: Added EPERM to the list of tolerated errors when creating the DLX cache symlink, as Windows can throw this when another process has the symlink open.
## Test plan
- [x] `parallel dlx calls of the same package` test passes on Windows CI
- [x] Full test suite passes on both Ubuntu and Windows
* refactor: rename rebuildSelectedPkgs/rebuildProjects to buildSelectedPkgs/buildProjects
The "rebuild" prefix is redundant now that these functions live in
@pnpm/building.after-install.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: rename Rebuild option types to Build (RebuildOptions → BuildOptions, etc.)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: rename plugin-commands-rebuild and exec.build-commands to building domain
- @pnpm/plugin-commands-rebuild → @pnpm/building.build-commands
- @pnpm/exec.build-commands → @pnpm/building.policy-commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move build-modules and pkg-requires-build to building domain
- @pnpm/build-modules → @pnpm/building.during-install
- @pnpm/exec.pkg-requires-build → @pnpm/building.pkg-requires-build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: alphabetically sort imports after package renames
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add changeset
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
Replace individual `.mpk` (MessagePack) files under `$STORE/index/` with a single SQLite database at `$STORE/index.db` using Node.js 22's built-in `node:sqlite` module. This reduces filesystem syscall overhead and improves space efficiency for small metadata entries.
Closes#10826
## Design
### New package: `@pnpm/store.index`
A new `StoreIndex` class wraps a SQLite database with a simple key-value API (`get`, `set`, `delete`, `has`, `entries`). Data is serialized with msgpackr and stored as BLOBs. The table uses `WITHOUT ROWID` for compact storage.
Key design decisions:
- **WAL mode** enables concurrent reads from workers while the main process writes.
- **`busy_timeout=5000`** plus a retry loop with `Atomics.wait`-based `sleepSync` handles `SQLITE_BUSY` errors from concurrent access.
- **Performance PRAGMAs**: `synchronous=NORMAL`, `mmap_size=512MB`, `cache_size=32MB`, `temp_store=MEMORY`, `wal_autocheckpoint=10000`.
- **Write batching**: `queueWrites()` batches pre-packed entries from tarball extraction and flushes them in a single transaction on `process.nextTick`. `setRawMany()` writes immediate batches (e.g. from `addFilesFromDir`).
- **Lifecycle**: `close()` auto-flushes pending writes, runs `PRAGMA optimize`, and closes the DB. A `process.on('exit')` handler ensures cleanup even on unexpected exits.
- **`VACUUM` after `deleteMany`** (used by `pnpm store prune`) to reclaim disk space.
### Key format
Keys are `integrity\tpkgId` (tab-separated). Git-hosted packages use `pkgId\tbuilt` or `pkgId\tnot-built`.
### Shared StoreIndex instance
A single `StoreIndex` instance is threaded through the entire install lifecycle — from `createNewStoreController` through the fetcher chain, package requester, license scanner, SBOM collector, and dependencies hierarchy. This replaces the previous pattern of each component creating its own file-based index access.
### Worker architecture
Index writes are performed in the main process, not in worker threads. Workers send pre-packed `{ key, buffer }` pairs back to the main process via `postMessage`, where they are batched and flushed to SQLite. This avoids SQLite write contention between threads.
### SQLite ExperimentalWarning suppression
`node:sqlite` emits an `ExperimentalWarning` on first load. This is suppressed via a `process.emitWarning` override injected through esbuild's `banner` option, which runs on line 1 of both `dist/pnpm.mjs` and `dist/worker.js` — before any module that loads `node:sqlite`.
### No migration from `.mpk` files
Old `.mpk` index files are not migrated. Packages missing from the new SQLite index are re-fetched on demand (the same behavior as a fresh store).
## Changed packages
121 files changed across these areas:
- **`store/index/`** — New `@pnpm/store.index` package
- **`worker/`** — Write batching moved from worker module into `StoreIndex` class; workers send pre-packed buffers to main process
- **`store/package-store/`** — StoreIndex creation and lifecycle management
- **`store/cafs/`** — Removed `getFilePathInCafs` index-file utilities (no longer needed)
- **`store/pkg-finder/`** — Reads from StoreIndex instead of `.mpk` files
- **`store/plugin-commands-store/`** — `store status` uses StoreIndex
- **`store/plugin-commands-store-inspecting/`** — `cat-index` and `find-hash` use StoreIndex
- **`fetching/tarball-fetcher/`** — Threads StoreIndex through fetchers; git-hosted fetcher flushes before reading
- **`fetching/git-fetcher/`, `binary-fetcher/`, `pick-fetcher/`** — Accept StoreIndex parameter
- **`pkg-manager/`** — `client`, `core`, `headless`, `package-requester` thread StoreIndex
- **`reviewing/`** — `license-scanner`, `sbom`, `dependencies-hierarchy` accept StoreIndex
- **`cache/api/`** — Cache view uses StoreIndex
- **`pnpm/bundle.ts`** — esbuild banner for ExperimentalWarning suppression
## Test plan
- [x] `pnpm --filter @pnpm/store.index test` — Unit tests for StoreIndex CRUD and batching
- [x] `pnpm --filter @pnpm/package-store test` — Store controller lifecycle
- [x] `pnpm --filter @pnpm/package-requester test` — Package requester reads from SQLite index
- [x] `pnpm --filter @pnpm/tarball-fetcher test` — Tarball and git-hosted fetcher writes
- [x] `pnpm --filter @pnpm/headless test` — Headless install
- [x] `pnpm --filter @pnpm/core test` — Core install, side effects, patching
- [x] `pnpm --filter @pnpm/plugin-commands-rebuild test` — Rebuild reads from index
- [x] `pnpm --filter @pnpm/license-scanner test` — License scanning
- [x] e2e tests pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This PR overhauls `pnpm env` use to route through pnpm's own install machinery instead of maintaining a parallel code path with manual symlink/shim/hardlink logic.
```
pnpm env use -g <version>
```
now runs:
```
pnpm add --global node@runtime:<version>
```
via `@pnpm/exec.pnpm-cli-runner`. All manual symlink, hardlink, and cmd-shim code in `envUse.ts` is gone (~1000 lines removed across the package).
### Changes
**npm and npx shims on all platforms**
Added `getNodeBinsForCurrentOS(platform)` to `@pnpm/constants`, returning a `Record<string, string>` with the correct relative paths for `node`, `npm`, and `npx` inside a Node.js distribution. `BinaryResolution.bin` is widened from `string` to `string | Record<string, string>` in `@pnpm/resolver-base` and `@pnpm/lockfile.types`, so the node resolver can set all three entries and pnpm's bin-linker creates shims for each automatically.
**Windows npm/npx fix**
`addFilesFromDir` was skipping root-level `node_modules/` (to avoid storing a package's own dependencies), which stripped the bundled `npm` from Node.js Windows zip archives. Added an `includeNodeModules` option and enabled it from the binary fetcher so Windows distributions keep their full contents.
**Removed subcommands**
`pnpm env add` and `pnpm env remove` are removed. `pnpm env use` handles both installing and activating a version. `pnpm env list` now always lists remote versions (the `--remote` flag is no longer required, though it is kept for backwards compatibility).
**musl support**
On Alpine Linux and other musl-based systems, the musl variant of Node.js is automatically downloaded from [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org).
* fix(tarball-resolver): add integrity hash to HTTP tarball dependencies
* Refactor to download tarball just once
* Fix tests
* fix: only calc hash when it is not passed in to the fetcher
* docs: update changesets