Commit Graph
11677 Commits
Author SHA1 Message Date
dependabot[bot] 9daea1ecb0 chore(cargo): bump bcrypt from 0.17.1 to 0.19.1 (#12062)
Bumps [bcrypt](https://github.com/Keats/rust-bcrypt) from 0.17.1 to 0.19.1.
- [Commits](https://github.com/Keats/rust-bcrypt/compare/v0.17.1...v0.19.1)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 17:12:08 +02:00
dependabot[bot] b5b1cd6e62 chore(cargo): bump tower-http from 0.6.8 to 0.6.11 (#12064)
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.8 to 0.6.11.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.8...tower-http-0.6.11)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 16:33:39 +02:00
faf848434b feat(pacquet/cli): --filter, --filter-prod (initial) (#12000)
* feat(pacquet/cli): add `--filter` / `--filter-prod` and workspace project filtering

Port pnpm's `@pnpm/workspace.projects-graph` and
`@pnpm/workspace.projects-filter` as the new
`pacquet-workspace-projects-graph` and `pacquet-workspace-projects-filter`
crates, and expose the `--filter` / `--filter-prod` CLI flags (stored into
`Config::filter` / `Config::filter_prod`).

- `parse_project_selector` parses selector strings (name glob, `...`
  dependents/dependencies, `^` exclude-self, `!` exclude, `{dir}`, `[since]`).
- `create_projects_graph` computes inter-project edges via `workspace:`,
  semver version/range, and local-path resolution.
- `filter_workspace_projects` resolves selectors against the graph;
  `filter_projects` builds the graph(s) and applies a `WorkspaceFilter` list.

The `[since]` changed-packages selector parses but is rejected with
`FilterError::UnsupportedDiffSelector` (git-diff project selection is not
ported). As with `--recursive`, the install still materializes every
workspace importer in one shared pass, so narrowing the install to the
selected projects remains a follow-up; the `known_failures` hoist stubs for
`--filter` selected-projects installs are unchanged.

Ports the upstream `parseProjectSelector` and `filterWorkspaceProjects` tests;
the diff-based cases are stubbed as `known_failures`. Updates
plans/TEST_PORTING.md.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* docs(pacquet/workspace-projects-filter): disambiguate intra-doc link

`parse_project_selector` is both a module and a re-exported function at
the crate root, so the bare intra-doc link was ambiguous and failed
`cargo doc` under `-D warnings`. Link to the function with `()`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-filter): faithful selector parse + graph parity

Review-driven fixes for parity with upstream `@pnpm/workspace.projects-filter`
and `projects-graph`:

- `parse_project_selector`: the hand-rolled selector matcher now backtracks
  like the upstream regex `^([^.][^{}[\]]*)?(\{[^}]+\})?(\[[^\]]+\])?$`. The
  name group's first char may be a brace/bracket, so `!{foo` is an exclude
  selector (was previously parsed as include, inverting selection) and
  `{[master]` keeps its `[master]` diff. Added cases.
- `create_projects_graph`: a path-style `workspace:` token (`workspace:../foo`)
  now resolves by directory rather than failing version resolution and
  emitting a spurious `unmatched` entry, matching upstream's
  `workspacePrefToNpm` + `npa.resolve` path.
- `glob::segment_match`: replaced the greedy single-pass match with a
  backtracking wildcard match so multiple `*` in one path segment
  (`{packages/a*-*}`) match correctly.
- Deduplicated `lexical_normalize`: it now lives in
  `pacquet-workspace-projects-graph` and the filter crate reuses it.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-graph): satisfy typos, perfectionist, rustdoc CI

- Rename closure params off single letters (`|p|` → `|project|`,
  `|&v|` → `|&version|`) to satisfy `perfectionist::single_letter_closure_param`.
- Use raw strings for the `.\` / `..\` path prefixes
  (`perfectionist::prefer_raw_string`) and add the missing `matches!`
  trailing comma (`perfectionist::macro_trailing_comma`).
- Disambiguate every `create_projects_graph` intra-doc link with `()`
  (module and re-exported function share the name), fixing
  `rustdoc::broken_intra_doc_links` under nightly.
- Rename the `unparseable_braces…` test to `unparsable_…` (typos).

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-filter): clear remaining Dylint findings; add filter-prod coverage

- Reorder derives to `prefix_then_alphabetical` (Default before Clone)
  for `perfectionist::derive_ordering`.
- Replace U+2026 `…` with ASCII `...` in comments
  (`perfectionist::unicode_ellipsis_in_comments`).
- Add coverage the cloud review flagged as missing: the `--filter-prod`
  production-only graph (dev edges dropped), the prod-then-all union
  order, and `unmatched_filters` for a path selector that matches
  nothing.
- Document the clap global-flag limitation: `--filter` occurrences only
  merge within one side of the subcommand boundary.

Verified locally with `cargo dylint --all` and `cargo doc -D warnings`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* test(pacquet/workspace-projects-filter): cover branches flagged by coverage

Add unit tests for every reachable line CodeCov flagged uncovered, and
delete the one unreachable branch:

- parser: empty input (`...`), empty brace group (`{}`), dot-prefixed
  name (`.foo`).
- filter: selector combining a directory and a name pattern, a selector
  with no name/dir/diff (`UnsupportedSelector`), and the `is_subdir`
  contract (incomparable / descendant / equal paths).
- glob: trailing `*` after an exact prefix.
- graph: `./`-segment collapse in a path spec, and the Windows
  drive-prefix predicate.
- Remove the unreachable `candidates.is_empty()` guard in
  `resolve_by_name_version` (`by_name` never holds empty vecs; `?`
  already covers absence).

All pure in-memory logic, so plain unit tests with constructed inputs
fit — no integration, filesystem fixtures, or DI seam needed. Verified
with `cargo dylint --all` and `cargo doc -D warnings`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* refactor(pacquet/workspace-projects-graph): reuse pacquet-fs lexical_normalize

The graph crate carried its own copy of `lexical_normalize`, duplicating
`pacquet-fs`'s already fully-tested helper. For every input the graph
passes (absolute project roots and absolute-base joins) the two behave
identically — they differ only on unanchored leading `..`, which never
reaches this code. Drop the duplicate and re-export the `pacquet-fs` one,
removing the lone uncovered `Component::CurDir` branch in the process
(the shared helper already covers it).

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* test(pacquet/workspace-projects-filter): cover already-walked node dedup

`pick_subgraph` skips a node it has already inserted via the
`walked.contains` guard, the branch that keeps the walk terminating on
diamonds and cycles. None of the existing fixtures reach a node by two
paths, so add a diamond graph (`top` -> {`left`, `right`} -> `shared`)
whose `...` dependency walk visits `shared` twice, exercising the guard.

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* docs(pacquet/config): trim filter doc to its stable contract

Drop the second paragraph narrating the current install behavior
("still materializes every importer ... follow-up ... stored for
parity"). That described a transient state that goes stale the moment
pacquet consumes the filter during install. Keep only the stable
contract: what the field holds, that it mirrors pnpm's CLI-only
`filter` array, and that only the CLI layer populates it.

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* fix(pacquet/workspace-projects-filter): concatenate absolute directory selectors like path.join

Upstream's `parseProjectSelector` resolves directory selectors with
`path.join(prefix, rel)`, which concatenates even when `rel` is absolute
(`path.join('/ws', '/pkg')` -> `/ws/pkg`). Rust's `Path::join` instead
drops `prefix` for an absolute `rel`, so `lexical_join` resolved a `{/pkg}`
selector to `/pkg` rather than `/ws/pkg`, diverging from pnpm.

Strip leading separators from `rel` before joining so an absolute
selector extends the prefix, and correct the doc comment, which described
the wrong (Rust) semantics. Add a parse test covering `{/pkg}`.

* fix(pacquet/workspace-projects-filter): normalize candidate path separators in dir globs

Address PR review comments:

- glob: normalize backslashes to `/` in the candidate path as well as the
  pattern, so a Windows `ProjectRootDir` rendered with backslashes by
  `PathBuf::to_string_lossy()` still matches a glob dir selector. Matches
  micromatch's effective separator handling.
- filter: include the offending selector in `UnsupportedSelector`,
  mirroring upstream's `Unsupported project selector: ${JSON.stringify(selector)}`.
- cli: add a regression test locking the documented clap mixed-placement
  limitation for the global `-F` flag.
- parse_project_selector: fix a doc typo ("directory directory-selectors").

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 16:18:45 +02:00
Zoltan Kochan 90fe4dc2d2 fix(package-manager): record configDependencies in workspace state (#12065)
* fix(package-manager): record configDependencies in workspace state

build_workspace_state hardcoded config_dependencies: None, so a pacquet
install wrote .pnpm-workspace-state-v1.json without the configDependencies
map. On the next pnpm run/node/exec, pnpm's checkDepsStatus compared the
live config's configDependencies against the missing recorded value, judged
node_modules out of sync, and reinstalled every time. When devEngines.runtime
is set with onFail: download, that reinstall also re-provisions the runtime.

Parse configDependencies from pnpm-workspace.yaml into Config (workspace-only,
cleared from global config.yaml like patchedDependencies) and write it through
build_workspace_state, mirroring pnpm's createWorkspaceState.

* refactor(package-manager): drop redundant comment in build_workspace_state

The fn doc comment and the WorkspaceSettings.config_dependencies field doc
already explain the createWorkspaceState parity and reinstall consequence.

* fix(config): model both configDependencies value shapes

The string-only Option<BTreeMap<String, String>> made an object-form
configDependencies entry ({ tarball?, integrity }) — valid in pnpm — fail
deserialization, turning a supported manifest into a hard config-load error.

Introduce ConfigDependency (untagged: VersionWithIntegrity string or
{ tarball?, integrity }) in the workspace-state crate and thread it through
Config and the workspace-state writer so both shapes round-trip verbatim,
matching pnpm. Also add the trailing comma perfectionist/dylint requires on
the new multi-line assert_eq! invocations.
2026-05-29 15:52:57 +02:00
Marvin Hagemeister 49e6074644 test: replace @pnpm/registry-mock with an in-repo in-process registry (#11927)
Replace the external `@pnpm/registry-mock` (Verdaccio) test dependency with an in-repo, in-process registry that serves package fixtures to **both** the pacquet Rust tests and the pnpm CLI (Jest) tests. No separately managed registry process is needed.

### How it works

- **Fixtures** live at `registry/.fixtures/packages/<name>/<version>/…`, moved verbatim from [`pnpm/registry-mock`](https://github.com/pnpm/registry-mock) (keyed by each `package.json`'s `name`+`version`).
- **`pnpm-registry-fixtures`** builds verdaccio-shaped storage from those fixtures; the in-tree **`pnpm-registry`** crate serves it.
  - Files whose names differ only by case (`@pnpm.e2e/with-same-file-in-different-cases`) and `bundleDependencies` trees are composed **in memory** by the builder, since neither can be committed to the working tree.
- **pacquet**: `pacquet-testing-utils`' `TestRegistry` starts the server lazily (once per process) in proxy mode, serving `@pnpm.e2e` fixtures locally and falling through to the npm uplink for real packages (`is-positive`, `is-negative`, …) — matching how registry-mock behaved.
- **pnpm CLI**: the `with-registry` Jest `globalSetup` builds storage from the fixtures via the new `pnpm-registry-prepare` binary (built from source in the Test CI job) and serves it with `pnpm-registry`. `REGISTRY_MOCK_PORT` / `REGISTRY_MOCK_CREDENTIALS` / `getIntegrity` now come from `@pnpm/testing.registry-mock`.

### Result

`@pnpm/registry-mock` is removed from every manifest, the catalog, and `packageExtensions`; `cargo test` / `cargo nextest run` / `just test` and the pnpm CLI Jest suites all run registry-backed tests without launching Verdaccio.
2026-05-29 14:35:45 +02:00
James GarbuttandZoltan Kochan 1e9ab2935f feat: support staged publishes in trust scale (#12056)
Fixes #11887.

Staged publishes now have a signal in the packument: `approver`.

If this is set, the package is more trustworthy than a "trusted publisher" package, since it requires 2FA publish approvals.

## Changes

**pnpm (TypeScript)**
- `getTrustEvidence` recognizes `_npmUser.approver` and classifies it as a new `stagedPublish` trust evidence, ranked above `trustedPublisher` and `provenance`.
- Trust-downgrade detection treats `stagedPublish` as the strongest rank, and the resolution verifier's PII-minimizing metadata projection retains the approver *signal* (without keeping the approver's name/email).

**pacquet (Rust port)**
- Ported the same staged-publish support: an `Approver` registry type, a `StagedPublish` trust evidence (rank 3 — above `TrustedPublisher`/`Provenance`), detection, pretty-printing, and the PII-stripping trust-meta projection.
- Wired `trustPolicy='no-downgrade'` enforcement into the **resolver-time** path, not just the lockfile verifier. Previously pacquet only re-checked entries already in `pnpm-lock.yaml`; fresh resolutions weren't gated. The npm resolver now runs `fail_if_trust_downgraded` on each freshly picked version (full metadata is already forced under this policy), mirroring pnpm's resolver-time `failIfTrustDowngraded` call.
- Ported the matching `trustChecks` tests for full parity with the TypeScript suite (staged-publish classification/downgrade, plus previously-unported `trustedPublisher → none`, no-evidence-anywhere, and exclude + missing-time cases).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 12:49:11 +02:00
Zoltan Kochan 98d4c60f61 fix(ci): inject release version into PACQUET_VERSION constant (#12058)
The pacquet release workflow patched the clap `version` attribute in
cli_args.rs, expecting a string literal. Since #12047 that attribute
references the `pacquet_config::PACQUET_VERSION` constant, so the perl
substitution matched nothing and the verifying grep failed, aborting the
whole release.

Patch the `PACQUET_VERSION` constant in defaults.rs instead. That single
constant feeds both `pacquet --version` and the default User-Agent, so
both report the published version.

Also tag the default User-Agent as `pnpm/pacquet-<version>` so registries
can tell pacquet's traffic apart from the TypeScript pnpm CLI, while
keeping the `pnpm` token for UA-keyed allow / rate-limit rules.
2026-05-29 12:06:13 +02:00
Zoltan Kochan 74dd8ba6e5 feat(config): make network settings configurable in pacquet (#12047)
* feat(config): make network settings configurable in pacquet

Port pnpm's `networkConcurrency`, `fetchTimeout`, `userAgent`, and
`npmrcAuthFile` to pacquet, replacing the hardcoded network-client
constants. Each is configurable via `pnpm-workspace.yaml`, the
`PNPM_CONFIG_*` env overlay, and a dedicated CLI flag, matching pnpm's
config surface.

- `pacquet-network`: add `NetworkSettings` (concurrency, timeout, UA)
  threaded into `ThrottledClient::for_installs`; `fetch_timeout` drives
  both the response and connect deadlines. `DEFAULT_FETCH_TIMEOUT_MS`
  (60s) and `DEFAULT_USER_AGENT` are the default sources.
- `pacquet-config`: add the four `Config` fields with cascade wiring;
  resolve `npmrcAuthFile` (and the `--userconfig` alias / env vars) to
  redirect the user-level `.npmrc` read in `Config::current`.
- Relocate the Rust->Node `host_platform`/`host_arch` mappers into
  `pacquet-detect-libc` (re-exported from `graph-hasher`) and reuse them
  to build pnpm's `pnpm/<version> npm/? node/? <platform> <arch>` UA.

Two intentional behaviour changes for parity: the default request
timeout moves from 300s to pnpm's 60s, and the default User-Agent moves
from the literal `pnpm` to pnpm's full UA format.

Refs https://github.com/pnpm/pnpm/issues/12042

* fix(config): correct npmrcAuthFile env resolution; port pnpm precedence tests

The user-level `.npmrc` path resolution applied the empty-string filter
once after the whole `or_else` chain, so an exported-but-empty
`PNPM_CONFIG_NPMRC_AUTH_FILE` short-circuited resolution instead of
falling through. Mirror pnpm's `readEnvVar` / `readNpmEnvVar` exactly:
filter `value !== ''` per variable, accept both `pnpm_config_*` /
`PNPM_CONFIG_*` cases, and add the `npm_config_userconfig` /
`NPM_CONFIG_USERCONFIG` compatibility fallback.

Port the translatable pnpm precedence tests from
`config/reader/test/index.ts` (resolution from the PNPM_CONFIG_* family,
lowercase variant, empty-falls-through, npmrc_auth_file outranks
userconfig, npm_config_userconfig compat fallback + pnpm-wins). pnpm's
credential-scoping tests that read the workspace .npmrc and the
userconfig simultaneously and re-scope per file don't translate yet:
pacquet uses a single-file (project-or-user) model, not pnpm's layered
merge. Global config.yaml sourcing of npmrcAuthFile remains deferred.

Refs https://github.com/pnpm/pnpm/issues/12042

* feat(config): merge multiple .npmrc sources with per-file credential scoping

Port pnpm's multi-file `.npmrc` layering and the credential-isolation
security boundary it provides. `Config::current` now reads the
user-level file (npmrcAuthFile / userconfig / ~/.npmrc), the global
`auth.ini`, and the project `.npmrc` together and merges them
(`user < auth.ini < workspace`) instead of reading just the first one
found.

Each source is rescoped before the merge (`NpmrcAuth::rescope_unscoped`,
ported from pnpm's `rescopeUnscopedCreds`): a file's *unscoped*
`_authToken` / `_auth` / `username` / `_password` / `cert` / `key` are
pinned to that file's own `registry=` (or the npmjs default when it
declares none), nerf-darted into a per-URI key. Once pinned, a
credential can never be pulled to a different registry that a
higher-priority file — or a later `pnpm-workspace.yaml` registry
override — sets. A deprecation warning is queued for each rescoped key.

`npmrcAuthFile` is now also sourced from the global `config.yaml`
(between `PNPM_CONFIG_USERCONFIG` and `npm_config_userconfig`),
completing pnpm's resolution order.

Behaviour changes for parity, both covered by ported tests: an unscoped
top-level `cert`/`key` becomes per-registry client identity (pinned to
its file's registry) rather than a global identity sent to every host;
and a user-file credential no longer leaks to a workspace registry
override. Ports the credential-scoping suite from
`config/reader/test/index.ts`.

Refs https://github.com/pnpm/pnpm/issues/12042

* fix(network): reject networkConcurrency=0; resolve user .npmrc base dir safely

Address review feedback on #12042:

- `ThrottledClient::for_installs` now returns
  `ForInstallsError::ZeroNetworkConcurrency` when `network_concurrency`
  is 0 instead of building a zero-permit semaphore that would hang every
  fetch. Matches pnpm, which rejects the value (p-queue requires a
  concurrency >= 1).
- When `npmrcAuthFile` is a bare filename with no parent, resolve its
  relative `cafile`/`certfile` entries against the process cwd (empty
  base path) rather than treating the file itself as the base directory.

* style(config): add trailing comma to multi-line format! (dylint)

Satisfies dylint's `perfectionist::macro-trailing-comma`, which the
pre-push hook enforces but `just ready` / clippy don't surface.

* style(config): fix "Unparseable" -> "Unparsable" typo in comment

Satisfies the Spell Check (crate-ci/typos) CI job.
2026-05-29 11:39:35 +02:00
Zoltan Kochan aa3135b9f2 feat(pacquet): port resolution/versions settings (#12042) (#12052)
Port the "Resolution / versions" section of the pnpm-parity tracking
issue to pacquet.

Full behavioral wiring:

- `resolutionMode` (`highest` / `time-based` / `lowest-direct`): direct
  deps pick lowest under `time-based`/`lowest-direct`; `time-based`
  additionally constrains transitive deps to a publish-date cutoff
  (newest resolved direct dep + 1h, clamped by `minimumReleaseAge`),
  computed workspace-wide in a pre-pass.
- `registrySupportsTimeField`: gates the full-metadata fetch the
  `time-based` cutoff (and the `no-downgrade` trust policy) need —
  `(time-based || no-downgrade) && !registrySupportsTimeField`.

Config-surface parity (parsed + stored, consumed once the surrounding
feature lands, as pacquet already does for other not-yet-active
settings):

- `allowedDeprecatedVersions` (no deprecation warnings yet),
- `updateConfig.ignoreDependencies` (no `update`/`outdated` command yet),
- `peerDependencyRules` (no peer-issue reporting pass yet).

Also treat `minimumReleaseAge: 0` as disabled (matching pnpm's falsy
`opts.minimumReleaseAge ? ... : undefined` check). While a maturity
cutoff is active the picker always prefers the highest mature version,
so this is what lets `lowest-direct`/`time-based` take effect on direct
deps.
2026-05-29 11:39:16 +02:00
Zoltan Kochan 64ce69aadc feat(package-manager): run project lifecycle scripts during install (#12051)
Port pnpm's project (workspace/root) lifecycle scripts that run during
`pnpm install` — preinstall, install, postinstall, preprepare, prepare,
postprepare — distinct from the dependency build scripts already run in
`BuildModules`.

- executor: extract the per-stage loop into a shared
  `run_lifecycle_stages`, keeping the `binding.gyp -> node-gyp rebuild`
  fallback and the `npx only-allow pnpm` skip identical for both paths.
  Add `run_project_lifecycle_scripts` + `PROJECT_LIFECYCLE_STAGES`,
  mirroring the `runLifecycleHooksConcurrently` call sites in
  pkg-manager/core and pkg-manager/headless.
- executor: honor `SelectedShell::windows_verbatim_args` — on the
  Windows `cmd /d /s /c` path the script body is now appended with
  `raw_arg` so embedded quoting (e.g. `node -e "..."`) reaches the child
  intact, matching Node's `windowsVerbatimArguments`. Previously a
  no-op, which mangled quoted scripts under cmd.exe.
- package-manager: run each project's scripts in `Install::run` after
  the dependency graph is materialized, bins are linked, and
  `.modules.yaml` / the current lockfile are written, before the closing
  `pnpm:summary`. Runs on both the frozen and fresh paths. A project
  script failure always fails the install via the new
  `InstallError::ProjectLifecycleScript` variant (unlike optional
  dependency build failures).
- gate on `Install::is_full_install`: `pacquet add` is a partial install
  (pnpm's `mutation: 'installSome'`), so the project's own scripts must
  not run — mirroring pnpm's `mutation === 'install'` filter.
- tests: stage ordering, re-run on --frozen-lockfile, failure
  propagation, name-differs-from-directory, INIT_CWD, and the
  `add`-skips-project-scripts gate.

Projects run root-first and sequentially; pnpm's buildIndex ordering and
child_concurrency fan-out are a follow-up once pacquet computes a
per-importer build index. No `ignoreScripts` gate yet — pacquet hardcodes
`ignore_scripts: false` across the dep-build path, so this matches pnpm's
default of running them.

Ref: https://github.com/pnpm/pnpm/blob/80037699fb/pkg-manager/core/src/install/index.ts#L1517-L1530
2026-05-29 11:38:38 +02:00
Ethan SetnikandZoltan Kochan 3cf2b86579 fix: preserve tarball dependency integrity in the lockfile (#12040)
* fix: preserve tarball dependency integrity in the lockfile

URL/tarball resolvers do not return an integrity (it is only known after
the tarball is downloaded). When a remote-tarball dependency was reused
from the lockfile without being re-fetched, the freshly resolved
resolution had no integrity and the existing one was dropped, breaking
subsequent --frozen-lockfile installs under the lockfile-integrity
hardening (ERR_PNPM_MISSING_TARBALL_INTEGRITY). Carry the integrity over
from the current resolution instead.

Closes #12001

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

* refactor(package-requester): simplify tarball integrity carryover guard

Align the integrity carryover added in the previous commit with its
sibling block in the download path: use `!resolution.type` (the idiom
already used there) and drop the `newIntegrity == null` clause, which is
redundant once `resolution` is the freshly resolved resolution.

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

* test(pacquet): cover tarball-dependency integrity preservation (#12001)

pnpm #12040 fixes dropping a remote-tarball dependency's integrity when
an unrelated package is installed afterwards. Pacquet can't reach that
scenario yet: a non-registry https-tarball direct dependency hits the
TarballResolver, which returns no name_ver/integrity, so lockfile build
panics with MissingSuffix. Add the regression test for the target
behavior, gated with allow_known_failure! until external tarball deps
install. Tracked in #12053.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 02:17:58 +00:00
Zoltan Kochan efcc33655f feat(registry): per-user access lists (verdaccio-style groups) (#12048)
* feat(registry): support multi-token access lists with usernames

Model each packages[*] access/publish permission as a list of tokens
(verdaccio's space-separated groups) instead of a single rule, and
match it against the caller's identity. Tokens are the built-in
pseudo-groups ($all/$authenticated/$anonymous and their @/bare
aliases) or a name; a name matches an authenticated caller whose
username equals it — verdaccio's per-user access, where the htpasswd
backend contributes the username and richer groups would come from a
group-providing auth backend.

- AccessRule (single enum) -> AccessList (Vec<AccessToken>) + Identity.
- Accept both a space-separated string and a YAML sequence for each
  permission, like verdaccio.
- Bare names are now valid tokens (drops the InvalidAccessRule
  stopgap from #12043 that rejected them).
- enforce_access: anonymous denial -> 401, authenticated denial -> 403.

Refs #11973.

* docs(registry): note the bare authenticated/anonymous token aliases

AccessToken::from also accepts the unprefixed `authenticated` /
`anonymous` forms; document them on the variants so they aren't
misread as usernames.
2026-05-29 02:17:28 +02:00
Zoltan Kochan 75e11a6a09 feat(pacquet/package-manager): port lockfileOnly setting (#12042) (#12046)
Ports pnpm's `--lockfile-only` to pacquet: resolve dependencies and
write `pnpm-lock.yaml` without fetching any tarball into the store or
materializing `node_modules`.

`lockfile-only` is in pnpm's `excludedPnpmKeys` (like `frozen-lockfile`),
so it's a pure per-invocation CLI flag with no `pnpm-workspace.yaml` /
`config.yaml` counterpart. It's threaded straight from the CLI through
`Install` / `Add`, mirroring the existing `frozen_lockfile` plumbing —
no config-crate changes.

- `--lockfile-only` flag added to `install` and `add`.
- `Install::run`: frozen / auto-frozen + `--lockfile-only` validates the
  on-disk lockfile (errors on stale, matching pnpm), re-persists the
  wanted lockfile, and returns without materializing. Otherwise the
  fresh-resolve path runs and `.modules.yaml`, the current lockfile, and
  the workspace-state file are skipped (mirrors pnpm skipping
  `updateWorkspaceState` under `lockfileOnly`).
- `InstallWithFreshLockfile::run`: skips the `PrefetchingResolver`
  wrapper so no tarball is fetched (matching pnpm's
  `dryRun: opts.lockfileOnly`), then writes `pnpm-lock.yaml` and returns
  before prefetch / virtual-store / symlink / hoist / bin steps.
- `--lockfile-only` with `lockfile: false` (pnpm's `useLockfile: false`)
  now fails with `ERR_PNPM_CONFIG_CONFLICT_LOCKFILE_ONLY_WITH_NO_LOCKFILE`,
  matching pnpm's `extendInstallOptions` guard.

Tests: new `crates/cli/tests/lockfile_only.rs` ports every lockfileOnly
test from `installing/deps-installer/test/` — the two in
`install/lockfileOnly.ts` (resolve-and-write with no download/link;
`--frozen-lockfile --lockfile-only` rejects a stale lockfile) plus the
two lockfileOnly-focused cases in `lockfile.ts` (the `useLockfile: false`
conflict and the workspace "new project added" importer update).
2026-05-29 02:07:40 +02:00
Zoltan Kochan a39a83d19e feat: support nodeLinker: hoisted on fresh installs + add hoistingLimits setting (#12041)
## 1. Support `nodeLinker: hoisted` on the fresh-lockfile install path (pacquet)

Closes #11871. Until now pacquet's `Install::run` hard-refused `nodeLinker: hoisted` without a checked-in lockfile (`ERR_PNPM_…UNSUPPORTED_FRESH_INSTALL_NODE_LINKER`).

- Extracted a shared `run_hoisted_linker` helper from the frozen path's hoisted branch (walker → `link_hoisted_modules` → `SymlinkDirectDependencies { link_only: true }` → `pkg_root_by_key` → walker-skip folding), so both install paths run identical logic.
- Fresh path now threads `node_linker` + `supported_architectures`, hands `CreateVirtualStore` the real linker (populating `cas_paths_by_pkg_id`), branches on `is_hoisted`, and returns `hoisted_locations` so `.modules.yaml` round-trips.
- Removed the guard and the dead `UnsupportedFreshInstallNodeLinker` error variant.

Ported upstream's `hoistedNodeLinker/install.ts` into `crates/cli/tests/hoisted_node_linker.rs` (real tests for the core layout, no-lockfile, `externalDependencies`, `autoInstallPeers`, and `hoistingLimits`; the rest stubbed as `known_failures` against `pnpm add`/update (#433) and build-phase (#11870) gaps), and ticked the boxes in `plans/TEST_PORTING.md`.

## 2. Add the `hoistingLimits` setting (pnpm CLI **and** pacquet)

Revives the stale #6468 (closes #6457) and brings both stacks to parity. `hoistingLimits` mirrors yarn's `nmHoistingLimits`: `none` (default — hoist as far as possible), `workspaces` (hoist only as far as each workspace package), `dependencies` (hoist only up to each workspace package's direct deps). It was previously a programmatic-only option in pnpm (no config surface) and a pacquet-only raw-map yaml field.

**pnpm CLI:** `config/reader` (`types.ts` enum + `Config.ts` + `configFileKey.ts`), `installing/linking/real-hoist`'s new `getHoistingLimits` (mode → the `@yarnpkg/nm` hoister's per-locator border map), and the install/add/recursive command option lists. Tests: `hoistedNodeLinker/install.ts` (`dependencies` mode) + `real-hoist` `getHoistingLimits` unit tests. Changeset included (minor).

**pacquet:** replaced the raw-map config with the same enum; added `get_hoisting_limits` (port of `getHoistingLimits`); and **fixed `real-hoist`'s border semantics** — a name in the limits marks a *border* whose descendants stay nested beneath it, not a leaf to block. (The earlier leaf-blocking behavior was the divergence flagged while porting; its unit tests were rewritten to the corrected semantics.)
2026-05-29 01:46:25 +02:00
Zoltan Kochan a59308230a feat(registry): enforce per-package access policy from YAML (#12043)
* feat(registry): enforce per-package access policy from YAML

Derive PackagePolicies from each packages[*] entry's access/publish
tokens in from_yaml/from_default_yaml, instead of hardcoding
registry_mock_defaults — configured ACLs were silently ignored before,
so a user-defined private scope got no protection.

- Add the $anonymous AccessRule: admits only unauthenticated callers;
  an authenticated caller falls outside the group and gets 403.
- Unknown access tokens now fail config parsing rather than silently
  mis-enforcing; named groups remain unsupported (tracked separately).
- Programmatic Config::proxy / Config::static_serve keep
  registry_mock_defaults.

Refs #11973.

* docs(registry): reword comments to satisfy typos spell check

`mis-enforce` tripped the `typos` check (read as `mis` → `miss`/`mist`).

* fix(registry): validate the unpublish access token too

build_policies validated access/publish but let unknown unpublish
tokens slip through. Validate it as well (defaulting to the publish
rule when absent) so the fail-closed behavior is consistent.
2026-05-29 01:18:46 +02:00
Juan PicadoandZoltan Kochan a9011ad83b feat(registry): add logging support for requests (#12033)
Adds YAML-driven logging to `pnpm-registry`. Format (`pretty` or `json`) and level come from a `log:` block in `config.yaml`. Every HTTP request emits one structured access record (method, URI, status, latency) under the `pnpm_registry::access` tracing target — pino-shaped. When `-c` isn't passed, the global `config.yaml` is auto-discovered using the same per-OS rules pnpm uses for its own config dir, under a `pnpr` leaf.

```yaml
log:
  type: stdout
  format: json
  level: info
```

## What's in

- New `LogConfig` / `LogFormat` / `LogLevel` types; defaults to pretty/info.
- `init_logging` picks `.json()` vs `.compact()` from the resolved config; `RUST_LOG` still overrides. JSON keeps the request span's `method`/`uri` on each access record (`with_current_span(true)`).
- `TraceLayer` emits exactly one INFO access record per request — both the span and the completion event under `target: "pnpm_registry::access"`, with tower-http's default emissions suppressed — so `LogLevel::Http`'s filter directive can scope to them.
- Global-config auto-discovery follows pnpm's `getConfigDir` rules (`XDG_CONFIG_HOME` / macOS `Library/Preferences` / Windows `LOCALAPPDATA` / `~/.config`) under a `pnpr` leaf. That resolution is shared with `pacquet-config` via a new dependency-free `pacquet-config-dir` crate, parameterized by app-name leaf.
- Verdaccio 6+ shape (`log:`, singular). The older plural `logs:` is silently ignored.
- `log.type` values other than `stdout` parse (verdaccio compatibility) but are ignored at runtime with a startup warning; only `stdout` is implemented.

## Notes

- File/syslog sinks are future work.
- New crate: `pacquet-config-dir` (std-only, used by both `pacquet-config` and `pnpm-registry`). New `pnpm-registry` deps: `home` and `tracing-subscriber`'s `json` feature — both already in `[workspace.dependencies]`.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 00:12:07 +02:00
Alessio AttilioandZoltan Kochan eb48bfd6c1 feat(registry): add /-/ping endpoint and shared config env var substitution (#12036)
This PR adds two features to the pnpm private registry:

- **`/-/ping` endpoint** — implements the npm `ping` endpoint (`GET /-/ping`), returning an empty JSON object `{}` with `200 OK`, matching npm's and verdaccio's behavior. Usable as a liveness/health check by clients and orchestrators.
- **Environment variable substitution in config** — the registry's YAML config now expands `${VAR}` and `${VAR:-DEFAULT}` placeholders before deserialization.

### Shared env-var substitution with pacquet

Rather than reimplement placeholder expansion, the registry reuses pnpm's exact semantics. The `${VAR}` substitution logic that already lived in `pacquet-config` (a faithful port of `@pnpm/config.env-replace`) is extracted into a new standalone crate, **`pacquet-env-replace`**, which both `pacquet-config` and the registry depend on. This means the registry inherits pnpm's behavior for free:

- `${VAR:-default}` falls back when the variable is set-but-empty, not just when it's absent.
- `\${VAR}` backslash escaping is honored.
- Unresolved placeholders are surfaced (logged via `tracing::warn!`) instead of being silently emptied — mirroring pnpm's lossy fallback (the OIDC `${VAR}`-leak guard, [#11513](https://github.com/pnpm/pnpm/issues/11513)).

`pacquet-config` re-exports `EnvVar` and keeps threading its own `Host` provider, so its behavior is unchanged.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-28 23:54:23 +02:00
Zoltan Kochan 50c42712bc fix(package-manager): match pnpm's GVS path segment for injected file: deps (#12039)
Under `enableGlobalVirtualStore: true`, pacquet built the slot path for an
injected `file:` workspace dep as `<store>/links/@/<name>/file:<path>/<hash>`,
embedding the raw depPath version. The `:` (and embedded `/`) make the path
invalid on Windows (`ERROR_INVALID_NAME`) and diverge from pnpm.

`gvs_version_segment` now mirrors pnpm's `nameVerFromPkgSnapshot` feeding
`formatGlobalVirtualStorePath`: a `file:` directory dep has no lockfile
`version` and a non-semver depPath, so upstream renders the literal segment
`undefined`. Pacquet does the same, keeping the colon out of the path while
matching pnpm's frozen-lockfile output byte-for-byte.

The materialise step itself already works (the directory fetcher landed with
the injectWorkspacePackages/dedupeInjectedDeps ports), so re-enable the
Windows-skipped e2e test and strengthen it to assert materialisation. Add a
GVS regression test covering the colon fix.

Closes #12038
2026-05-28 23:00:22 +02:00
Zoltan Kochan ddf4ec4612 feat(pacquet): port dedupeInjectedDeps (#12023)
Ports pnpm's [`dedupeInjectedDeps`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-resolver/src/dedupeInjectedDeps.ts) to pacquet end-to-end, and restructures the resolver to match pnpm's multi-importer shape so the dedupe lives where pnpm puts it.

- **Config plumbing** — `dedupe_injected_deps: bool` on `Config` (default `true`), read from `pnpm-workspace.yaml`'s `dedupeInjectedDeps` key, overridable via `PNPM_CONFIG_DEDUPE_INJECTED_DEPS`. Cleared as a workspace-only field in `WorkspaceSettings::clear_workspace_only_fields`.
- **`dependenciesMeta.injected` plumbing** — pacquet's deps-resolver previously constructed `WantedDependency` with `..Default::default()`, so the per-package `dependenciesMeta[<alias>].injected: true` flag never reached the npm/local resolvers and no install path produced a `file:<workspace>` direct dep. Reading `dependenciesMeta` at the importer-level wanted-dep collection unlocks the `file:` workspace-pick branch the dedupe consumer is designed to collapse.
- **Multi-importer resolver refactor** — new `resolve_workspace` orchestrator (`pacquet/crates/resolving-deps-resolver/src/resolve_workspace.rs`) mirrors pnpm's [`resolveDependencies(importers, opts)`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-resolver/src/index.ts#L128). It constructs one shared `WorkspaceTreeCtx` (resolved-pkgs dedup, children-spec cache, resolver-call memo, peer-walker seed sets), hands `Arc::clone` to every per-importer `resolve_importer_with_workspace`, then runs a single `resolve_peers_workspace` pass that shares `peersCache` + `purePkgs` across importers. Importer N's tree walk now reuses importer M's resolver hits instead of re-running the chain.
- **`dedupeInjectedDeps` lives in `resolve_peers_workspace`** (`pacquet/crates/resolving-deps-resolver/src/dedupe_injected_deps.rs`), matching pnpm's `resolvePeers` integration. The install layer no longer carries any dedupe wiring; it just hands importers + a per-importer `ResolveOptions` closure to `resolve_workspace`. After dedupe, unreachable `file:<workspace>` snapshots are pruned from the graph so they don't leak into `pnpm-lock.yaml`.
- **`ImporterDepVersion::File` arm** — when `dedupeInjectedDeps: false` leaves an injected workspace dep as `file:packages/<name>` at the importer level, the lockfile writer used to panic at `importer_dep_version` (the `parse::<PkgNameVerPeer>().expect(...)` arm). Adds a `File(String)` variant to `ImporterDepVersion`, wires it through `dependencies_graph_to_lockfile` and `symlink_direct_dependencies`, and the new e2e test `injected_workspace_dep_with_dedupe_off_writes_file_arm` exercises that path end-to-end.
- **Workspace state** — surfaces `dedupe_injected_deps` in `current_settings` and adds it to the `settings_match` comparison in `optimistic_repeat_install`; drops it from the "deliberately not compared" list so settings drift now triggers a reinstall.

Tracked under pnpm/pnpm#12009 (one item; the rest are separate PRs).
2026-05-28 22:04:43 +02:00
Zoltan Kochan bf3cc86952 feat(pacquet/package-manager): port dedupeDirectDeps setting (#12024)
Ports pnpm's [`dedupeDirectDeps`](https://pnpm.io/settings#dedupedirectdeps) setting to pacquet end-to-end — one of the items tracked in #12009.

When the workspace root resolves an alias to the same target as a non-root project, the non-root project skips the symlink under its own `node_modules/`. A project whose direct deps are entirely covered by root ends up without a `node_modules/` at all. Matches pnpm's [`linkDirectDepsAndDedupe`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/linking/direct-dep-linker/src/linkDirectDeps.ts#L34) in all three regimes (direct-vs-direct, direct-vs-public-hoist, direct-vs-shamefully-hoist), on both the fresh-install and frozen-lockfile paths.

Default `true`, matching pnpm. Set `dedupeDirectDeps: false` in `pnpm-workspace.yaml` to opt out.

## What changed

- **Config:** new `dedupe_direct_deps: bool` field on `Config` (default `true`), wired through `pnpm-workspace.yaml`, the `DEDUPE_DIRECT_DEPS` env override, and `clear_workspace_only_fields`.

- **Linker:** `SymlinkDirectDependencies::run` computes the root importer's resolved targets up front and filters non-root importers against that map. Pulled out helpers `collect_resolved_entries` / `resolve_target_path` / `collect_resolved_targets` so the dedupe pass and the symlink pass share one target-path computation. The `link_only` (hoisted) path participates too — `link:` workspace siblings dedupe consistently. Link-target paths are run through `pacquet_fs::lexical_normalize` before equality comparison so `<workspace>/packages/a` and `<workspace>/packages/sibling/../a` correctly dedupe (matching pnpm, whose `path.relative` normalizes via `path.resolve`).

- **Hoist + dedupe interaction:** pacquet's pipeline runs `SymlinkDirectDependencies` *before* the on-disk hoist phase, so the dedupe map would have missed publicly-hoisted root entries that pnpm sees because hoist runs first there. Lifted the hoist plan computation into `pub(crate)` helpers (`compute_hoist_plan`, `collect_public_hoist_targets`, `HoistPlan`) — pure BFS, no I/O — and pass the publicly-hoisted alias → target-path map into the dedupe pass via a new `public_hoist_targets` field. The on-disk hoist phase reuses the same `HoistResult` instead of re-running the BFS.

- **Fresh-install hoist:** pacquet's fresh-install path previously didn't run hoist at all, so `pacquet install` (no `--frozen-lockfile`) never produced `<vs>/node_modules/<alias>` (private hoist) or `<root>/node_modules/<alias>` (public hoist) entries — a parity gap with pnpm independent of dedupe. The same helpers are now called from `install_with_fresh_lockfile.rs` after `CreateVirtualStore`. The fresh-install result's `hoisted_dependencies` slot now carries the real map (was always empty before).

- **Workspace state:** `current_settings` writes `dedupeDirectDeps`, `settings_match` participates in the comparison. Flipping the flag trips the optimistic-repeat-install fast-path gate.

## Tests

`pacquet/crates/cli/tests/dedupe_direct_deps.rs` (new) — 7 integration tests against the real binary + mocked registry:

- `dedupes_direct_deps_against_workspace_root_by_default` — default-on dedupes against root (sibling has no `node_modules`)
- `dedupe_direct_deps_disabled_keeps_per_project_symlinks` — `dedupeDirectDeps: false` keeps per-project symlinks
- `dedupes_only_overlapping_direct_deps` — partial dedupe (one shared, one unique)
- `dedupes_direct_deps_with_frozen_lockfile` — frozen-lockfile install dedupes too
- `dedupes_link_deps_resolving_to_the_same_dir_via_different_segments` — `link:packages/a` vs `link:../a` dedupes via lexical normalization
- `dedupes_direct_dep_against_publicly_hoisted_root_dep` — mirrors `installing/deps-installer/test/install/dedupeDirectDeps.ts:113`, exercises dedupe-vs-public-hoist via the frozen path
- `dedupe_under_shamefully_hoist` — mirrors `pnpm/test/install/hoist.ts:77`, exercises dedupe + shamefully-hoist on the fresh-install path

Plus:
- `returns_skipped_when_dedupe_direct_deps_drifts` — drift test on the optimistic-repeat-install settings gate.
- Two snapshot tests (`add__should_install_all_dependencies`, `add__should_symlink_correctly`, `install__should_install_dependencies`) updated to include the new `node_modules/.pnpm/node_modules/<scope>/<alias>` private-hoist entries that fresh install now produces (matches pnpm's default `hoistPattern: ["*"]`).

All upstream tests referencing `dedupeDirectDeps` are ported.
2026-05-28 21:31:05 +02:00
Zoltan Kochan 2cca6ab9df feat(package-manager): port packageExtensions setting (#12027)
Ports the `packageExtensions` setting from pnpm to pacquet, end-to-end. Refs pnpm/pnpm#12009.

## What lands

**Config + yaml plumbing** (`pacquet-config`):
- `PackageExtension` / `PeerDependencyMeta` structs (mirror pnpm's `Pick<BaseManifest, 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'peerDependenciesMeta'>`).
- `WorkspaceSettings::package_extensions` with yaml deserialization, `clear_workspace_only_fields` entry, and empty-map collapse (matches the `overrides` shape).
- `Config::package_extensions` field.
- `PNPM_CONFIG_PACKAGE_EXTENSIONS` env var.

**Hook** (`pacquet-package-manager::PackageExtender`):
- Port of pnpm's [`createPackageExtender`](https://github.com/pnpm/pnpm/blob/39101f5e37/hooks/read-package-hook/src/createPackageExtender.ts): groups extensions by package name once, then applies semver range filter and merges fields onto the manifest. Manifest fields win over extension fields on conflict (matches upstream's `{ ...extension, ...manifest }` spread order).
- `apply_to_arc` deep-clones the inner `Value` only when an extension matches — unrelated manifests keep sharing the resolver's cached `Arc`.

**Resolver wiring** (`pacquet-resolving-deps-resolver`):
- New `ManifestHook` type, threaded through `ResolveDependencyTreeOptions` / `ResolveImporterOptions` / `TreeCtx`.
- Applied in `resolve_node` right after `Resolver::resolve` returns and before the result enters the wanted-dep cache — same site as pnpm's [`ctx.readPackageHook(pkg)`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-resolver/src/resolveDependencies.ts#L1481-L1483) call.
- `install_with_fresh_lockfile` builds the hook from `Config.package_extensions` once per install.

**Workspace state / drift check** (`optimistic_repeat_install`):
- `current_settings` now emits `package_extensions` so `.pnpm-workspace-state-v1.json` round-trips through pnpm.
- `settings_match` compares the field (treating empty map as absent for cross-implementation parity).
- Removed from the "deliberately not compared" list in `settings_match`.

**Lockfile checksum + frozen-install drift gate** (`pacquet-graph-hasher`, `pacquet-lockfile`):
- New `hash_object_nullable_with_prefix` in `pacquet-graph-hasher` — port of pnpm's [`hashObjectNullableWithPrefix`](https://github.com/pnpm/pnpm/blob/39101f5e37/crypto/object-hasher/src/index.ts#L44-L48), byte-for-byte identical (tests pin against the known `sha256-48AVoXIXcTKcnHt8qVKp5vNw4gyOB5VfztHwtYBRcAQ=` output upstream's test asserts).
- New `Lockfile::package_extensions_checksum` top-level field (matches upstream's wire shape).
- `install_with_fresh_lockfile` computes and writes the checksum on every fresh install; `current_lockfile` clones it into the materialized lockfile.
- `check_lockfile_settings` gained a `package_extensions_checksum` argument and a new `StalenessReason::PackageExtensionsChecksumChanged` variant. The check fires after `overrides` and before `ignoredOptionalDependencies` — matches upstream's ordering at [`getOutdatedLockfileSetting.ts:53-55`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L53-L55) so the first-drift error reads the same on both sides.
2026-05-28 21:21:44 +02:00
Zoltan Kochan a0eb2ad778 feat(pacquet): port injectWorkspacePackages setting (#12021)
Ports `injectWorkspacePackages` to pacquet end-to-end — config layer, resolver (global + per-dep), lockfile, and freshness gates — closing one of the eight settings tracked at #12009.

### What the feature does

When set globally, workspace-package resolutions materialize as `file:` (hard-linked copies into the virtual store) instead of `link:` symlinks back to the source. Per-dependency `dependenciesMeta[<name>].injected = true` opts a single dep into the same behavior even when the global flag is off.

### Changes

**Config layer (commit b3a68957f8):**
- Adds `inject_workspace_packages: bool` to `Config` (default `false`, matching pnpm's `'inject-workspace-packages': false`).
- Adds the field to `WorkspaceSettings` so it parses from `pnpm-workspace.yaml` (camelCase).
- Threads `config.inject_workspace_packages` into `ResolveOptions` at the install entry point.
- Joins it to the workspace-state freshness comparison: `current_settings` writes the value, `settings_match` compares it, a flip between runs invalidates the optimistic-repeat-install cache.

**End-to-end pipeline (commit 44e2ea918a):**
- `LockfileSettings.inject_workspace_packages` round-trips through the v9 lockfile. `false` omits the key on save, matching pnpm's [`lockfileFormatConverters.ts:70-72`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/fs/src/lockfileFormatConverters.ts#L70-L72).
- `dependencies_graph_to_lockfile` populates the new field from `Config.inject_workspace_packages`.
- `check_lockfile_settings` adds a Boolean-normalized drift gate for `settings.injectWorkspacePackages`, mirroring upstream's [`getOutdatedLockfileSetting.ts:80-82`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L80-L82).
- `build_pkg_id_with_patch_hash` now prefixes file:/git:/tarball: resolutions with `name@` when the resolver didn't set `name_ver`, reading the name from the manifest — matching pnpm at [`resolveDependencies.ts:1502-1507`](https://github.com/pnpm/pnpm/blob/097983fbca/installing/deps-resolver/src/resolveDependencies.ts#L1502-L1507). Without this, file: dep paths end up unprefixed and `PkgNameVerPeer`'s `@`-split parser fails on the `@` inside the peer suffix. `link:` ids are deliberately exempted so the downstream `is_link` short-circuit still triggers.

**Cross-platform fix (commit 38a1db9cc1):**
- `PkgNameVerPeer::to_virtual_store_name` previously escaped only `/`, leaving `:` in `file:`-resolution slot names. NTFS / FAT reject these with `ERROR_INVALID_NAME (123)`, but APFS / ext4 accept them, so the bug only surfaced once a workspace-injected dep hit the Windows CI of this PR. The escape now covers the full Windows-invalid charset `[\\/:*?"<>|#]`, matching upstream's [`depPathToFilename` regex](https://github.com/pnpm/pnpm/blob/1819226b51/deps/path/src/index.ts#L169-L170).

**Per-dep `dependenciesMeta` thread (commit 7cc0f98bd7):**
- `importer_injected_dependency_names(manifest)` reads `dependenciesMeta` from the importer manifest and returns the names whose `injected` flag is `true`.
- The wanted-spec tuple in `extend_tree` / `resolve_catalog_specifiers` widens from `(alias, range, optional)` to `(alias, range, optional, injected)`. `extend_tree` lifts the spec's `injected` onto `WantedDependency::injected` as `Some(true)` / `None`, matching pnpm's `injected: opts.dependenciesMeta[alias]?.injected` shape so absent meta yields `None` (not `Some(false)`).
- `WantedKey` cache key gains a fourth slot for `injected` — the workspace branch of the npm resolver emits a `file:` resolution when injected and a `link:` one otherwise, so two importers asking for the same workspace dep with different per-dep flags must take different cache buckets.
- Importer-only scope: the child-spec walker keeps its 3-tuple shape; no resolved package's own `dependenciesMeta` is inherited. Hoisted-required / hoisted-optional peer arms default `injected` to `false` since they construct fresh wanteds without dep meta.

Refs #12009.
2026-05-28 21:03:45 +02:00
Zoltan Kochan 4b60515af5 feat(pacquet/config): port peersSuffixMaxLength setting (#12026)
Ports the [`peersSuffixMaxLength`](https://pnpm.io/settings#peerssuffixmaxlength) setting (default `1000`) to pacquet end-to-end, matching pnpm:

- read from `pnpm-workspace.yaml`, global `config.yaml`, and `PNPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH`
- threaded into `ResolveImporterOptions` so the deps resolver caps the rendered peer-suffix at the configured value (was hardcoded `1000` via `ResolvePeersOptions::default()` before)
- written to `pnpm-lock.yaml`'s `settings.peersSuffixMaxLength` only when non-default — mirrors pnpm's strip-on-default in [`convertToLockfileFile`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/fs/src/lockfileFormatConverters.ts#L67-L69)
- `check_lockfile_settings` now reports drift as `StalenessReason::PeersSuffixMaxLengthChanged`
- joins `optimistic_repeat_install`'s `settings_match` comparison via `current_settings`, so a change here invalidates the fast path

Closes one of the checkboxes tracked in #12009.
2026-05-28 20:17:29 +02:00
Zoltan Kochan e375a58261 chore: run cargo fmt, doc, and dylint checks in husky pre-push (#12035)
The Rust workspace's pre-push checks were sitting in
`pacquet/.githooks/pre-push` and only fired if a developer ran
`just install-hooks`, which would also disable every husky-managed
TypeScript hook by replacing `core.hooksPath`. Move the bash logic to
`pacquet/scripts/pre-push-rust.sh`, invoke it from `.husky/pre-push`
alongside the existing TS compile and lint checks, and drop the
`install-hooks` recipe so nobody re-points `core.hooksPath` by mistake.

The script now also runs `cargo doc --no-deps --workspace --all-features`
(with `RUSTDOCFLAGS=-D warnings`) and `cargo dylint --all -- --all-targets
--workspace` (with `RUSTFLAGS=-D warnings`), matching CI. `--workspace`
covers both `pacquet/crates/*` and `registry/crates/*` since they share
the root Cargo workspace.
2026-05-28 20:16:10 +02:00
Zoltan Kochan 7c9a6c29ea feat(pacquet/config): port preferWorkspacePackages setting (#12032)
Port the `preferWorkspacePackages` setting from pnpm. When enabled,
a workspace package wins over a newer registry pick during resolution.
Default `false`, matching pnpm.

- Config plumbing: `Config.prefer_workspace_packages`,
  `WorkspaceSettings.prefer_workspace_packages`,
  `PNPM_CONFIG_PREFER_WORKSPACE_PACKAGES` env overlay, and the
  `clear_workspace_only_fields` / `apply_to` wiring.
- Install pipeline: thread `config.prefer_workspace_packages` into
  the `ResolveOptions` built in `install_with_fresh_lockfile`. The
  npm resolver already consumes this flag in `try_workspace_shadow`.
- Optimistic-repeat-install drift: `WorkspaceStateSettings.prefer_workspace_packages`
  is now compared by `settings_match` and written by `current_settings`.
  A switch to the setting between installs invalidates the cached-modules
  fast path.

Closes one of the items on #12009.
2026-05-28 19:21:28 +02:00
Zoltan Kochan 82e31d6627 feat(pacquet): port excludeLinksFromLockfile (#12025)
Port the `excludeLinksFromLockfile` setting to pacquet so its behavior matches the TypeScript pnpm CLI (which has had this setting since [pnpm/pnpm#6570](https://github.com/pnpm/pnpm/pull/6570)). Refs [pnpm/pnpm#12009](https://github.com/pnpm/pnpm/issues/12009).

When `excludeLinksFromLockfile: true` is set, pacquet now:

- Omits bare `link:` direct dependencies from each importer's `pnpm-lock.yaml` entry (keeping `workspace:`-resolved links recorded).
- Remaps external link targets to a stable `link:<rel-from-lockfile_dir-to-modules_dir>/<alias>` node id when seeding peer-resolution parents, so peer suffixes for snapshots that depend on a linked package don't carry the absolute path of the external link. The remap fires only when the link target is *outside* the lockfile root (per upstream's `isSubdir` gate).
- Round-trips the setting into the lockfile's top-level `settings:` block so `getOutdatedLockfileSetting` can spot drift on the next install.

## Where it's hooked (mirrors pnpm)

- **Config flag**: `pacquet-config` adds `exclude_links_from_lockfile: bool` (default `false`) — mirrors [`config/reader/src/Config.ts:71`](https://github.com/pnpm/pnpm/blob/094aa6e57b/config/reader/src/Config.ts#L71) and the [`false` default](https://github.com/pnpm/pnpm/blob/094aa6e57b/config/reader/src/index.ts#L144).
- **Importer entry**: `dependencies_graph_to_lockfile::build_importer` mirrors upstream's [`addDirectDependenciesToLockfile` exclude-link gate](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/index.ts#L449-L456) — drops `link:` direct deps from the importer entry unless their manifest specifier starts with `workspace:`.
- **Peer-resolution remap**: `resolve_peers::build_importer_parents` ports the [`target` rewrite at `index.ts:232-244`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/index.ts#L232-L244). External `link:` parents are seeded into `ParentRefs` with a remapped node id (`link:<rel-from-lockfile_dir-to-modules_dir>/<alias>`), so the peer suffix stays stable across machines.
- **Peer-id translation**: `build_peer_id` special-cases `link:` node ids and emits `PeerId::Pair { name: peer_alias, version: link_path_to_peer_version(rel) }` — the exact port of upstream's [`peerNodeIdToPeerId` link arm](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/resolvePeers.ts#L984-L989).
- **Snapshot child fallback**: a peer-resolved child whose node id is `link:<rel>` and isn't in `node_dep_paths` uses the link node id verbatim as the snapshot child ref — mirrors upstream's [`pathsByNodeId.get(childNodeId) ?? (childNodeId as DepPath)`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/resolvePeers.ts#L164) in `resolveChildren`.

## New shared helpers

- `pacquet-deps-path::link_path_to_peer_version` — faithful port of upstream's [`linkPathToPeerVersion.ts`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/linkPathToPeerVersion.ts). Iterates Unicode scalars (not raw bytes), so multi-byte UTF-8 path segments round-trip intact; pinned by a `non_ascii_path_segments_round_trip` test covering Latin-1, CJK, and a non-BMP emoji.
- `pacquet-fs::is_subdir` — promoted from a private `pacquet-cmd-shim` helper so the resolver and the cmd-shim share one implementation. Mirrors npm's [`is-subdir`](https://github.com/zkochan/packages/blob/main/is-subdir/index.js).
2026-05-28 19:16:51 +02:00
Zoltan Kochan 7ecaf3d5a6 feat(pacquet/deps-resolver): port dedupePeers setting (#12022)
Port the [`dedupePeers`](https://pnpm.io/settings#dedupepeers) setting from pnpm. When enabled, peer-dependency suffixes in `depPath`s use version-only identifiers (`name@version`) instead of recursive dep paths, collapsing nested suffixes like `(@emotion/react@11.0.0(react@18.0.0))` into `(@emotion/react@11.0.0)`. Default `false`, matching pnpm.

- **Config plumbing:** `Config.dedupe_peers`, `WorkspaceSettings.dedupe_peers`, `PACQUET_DEDUPE_PEERS` env overlay, and the `clear_workspace_only_fields` / `apply_to` wiring.
- **Resolver behavior:** `ResolvePeersOptions.dedupe_peers` threaded through `ResolveImporterOptions` and consumed in `Walker::build_peer_id` — when on, emits `PeerId::Pair { name, version }` from the resolved package instead of the peer's own `DepPath`. Mirrors pnpm's [`peerNodeIdToPeerId` `dedupePeers` branch](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-resolver/src/resolvePeers.ts#L990-L997).
- **Lockfile round-trip:** `LockfileSettings.dedupe_peers: Option<bool>` (omitted when `false`, mirroring pnpm's `dedupePeers: opts.dedupePeers || undefined`). `GraphToLockfileOptions.dedupe_peers` plumbed from `install_with_fresh_lockfile`.
- **Optimistic-repeat-install drift:** `WorkspaceStateSettings.dedupe_peers` is now compared by `settings_match` and written by `current_settings`. A switch to the setting between installs invalidates the cached-modules fast path.

Closes one of the items on #12009.
2026-05-28 17:58:20 +02:00
Abdullah AlaqeelandZoltan Kochan 2cadfb5d3d refactor: replace enquirer with @inquirer/prompts (#11942)
Replaces the unmaintained `enquirer` package with `@inquirer/prompts` for all interactive CLI prompts. Fixes the `update -i` scrolling overflow bug where long choice lists were clipped in the terminal.

Fixes #6643

## User-facing changes

- **`pnpm update -i` / `pnpm update -i --latest`**: Scrolling now works correctly when many packages are available; the new library uses visual-line-aware pagination via `usePagination`
- **`pnpm audit --fix -i`**: Same scrolling fix for vulnerability selection
- **`pnpm approve-builds`**: Interactive build approval prompts updated
- **`pnpm patch`**: Version selection and "apply to all" prompts updated
- **`pnpm patch-remove`**: Patch removal selection updated
- **`pnpm publish`**: Branch confirmation prompt updated
- **`pnpm login`**: Credential prompts updated
- **`pnpm run` / `pnpm exec`** (with `verifyDepsBeforeRun=prompt`): Confirmation prompt updated

## Internal changes

- `OtpEnquirer` DI interface changed from `{ prompt }` to `{ input }`
- `LoginEnquirer` DI interface changed from `{ prompt }` to `{ input, password }`
- `enquirer` removed from catalog and all 8 package.json files
- `@inquirer/prompts` v8.4.3 added to catalog and all 8 package.json files
- Removed `OtpPromptOptions` and `OtpPromptResponse` exports from `@pnpm/network.web-auth` (no longer needed)

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-28 17:53:52 +02:00
Zoltan Kochan 39101f5e37 fix: hang on cyclic aliased peer dependency (#12018)
- `pnpm i nuxt@npm:nuxt-nightly@5x` (and similar aliased installs) hung at 0% CPU during peer resolution after `resolved N, reused 0, downloaded N, added 0`.
- `resolvePeers.calculateDepPath` only short-circuited cycles whose members included `currentAlias`. When two peers form a mutual cycle (e.g. `vite` ↔ `@vitejs/devtools`) and both hit the `findHit` cache instead of running their own `calculateDepPath`, the cycle surfaced at a level where no participant could break it — a sibling's `calculateDepPath` saw the cycle in the `cycles` argument but kept awaiting `pathsByNodeIdPromises` on cyclic peer node IDs.
- The fix expands `cyclicPeerAliases` to also include any cycle that intersects the current call's pending peers, so awaiting siblings emit the `name@version` peer id and the cached promise gets released.
- Pacquet's `resolve_peers` walks synchronously with an `in_progress` set and returns already-realized `DepPath` values from `find_hit`, so the deadlock does not occur there. A pacquet regression test locks in that the aliased-install + transitive-mutual-peer scenario terminates with the expected graph entries.

Closes #11999.
2026-05-28 13:30:49 +02:00
Zoltan Kochan b1fa2d5979 fix(dist-tag): support npm's web-based 2FA flow (#11998)
* fix(dist-tag): open the browser for npm web 2FA when --otp is absent

Without `--otp`, `pnpm dist-tag add` (and `rm`) failed against
npmjs.org with `[ERR_PNPM_UNAUTHORIZED] You must be logged in to set
dist-tag … "You must provide a one-time pass. Upgrade your client to
npm@latest in order to use 2FA."` — the browser never opened. The
fallback "upgrade your client" message is what npmjs.org returns when
the client doesn't announce `npm-auth-type: web`; without that header
the server skips the web challenge and tells the user to install a
newer npm. `--otp=<6-digit code>` already worked because the OTP went
out in `npm-otp` directly.

Send `npm-auth-type: web` on dist-tag writes when no `--otp` is
given, surface 401 responses carrying `authUrl`/`doneUrl` (or the
legacy "one-time pass" text) as `SyntheticOtpError`, and wrap the
call in the existing `withOtpHandling` helper (already used by
`pnpm publish`), which opens the browser, polls the done URL, and
retries with the resulting token as `npm-otp` while keeping
`npm-auth-type: web` in place.

Drive-by cleanup in `@pnpm/network.fetch`: the abbreviated-metadata
`Accept` header is no longer attached to non-GET requests, matching
`npm-registry-fetch`'s behavior.

* fix(dist-tag): default authType to 'web'; inherit network config in OTP context

Two review fixes:

- `setDistTag` documented `authType` as defaulting to `'web'` but only
  sent the `npm-auth-type` header when the field was explicitly passed.
  Always send the header, defaulting to `'web'`.

- `OTP_CONTEXT` used a module-level `createFetchFromRegistry({})`, so
  the `withOtpHandling` doneUrl poll ignored the command's proxy / TLS
  / `configByUri` config. Build the OTP context per call from the
  command's `opts` instead.

Also rename `toOtpOrUnauthorizedError` → `parseAuthError` and drop the
spurious `async` (the body string is already awaited at the call site).
2026-05-28 12:24:29 +02:00
Silas Rech 623542873a fix(npm-resolver): revalidate excluded packages instead of trusting mtime cache (#12010)
Skip the publishedBy file-mtime fast path for fully excluded packages so stale abbreviated metadata cannot pin older versions, and add matching regression tests.
2026-05-28 01:26:22 +00:00
Zoltan Kochan 8c2d53bea1 feat(registry): add whoami, profile, and token CRUD endpoints (#12011)
Wires the five remaining auth surfaces from the [pnpr parity tracking issue](https://github.com/pnpm/pnpm/issues/11973) — everything under **Auth & user endpoints** except the pluggable-auth decision.

- `GET /-/whoami` — `{ username }` for the bearer caller, 401 anonymous.
- `GET /-/npm/v1/user` — profile shape (`name`, `tfa`, `email`, `cidr_whitelist`, `fullname`) that `npm profile get`'s table renderer parses cleanly.
- `GET /-/npm/v1/tokens` — `{ objects, urls }` listing only the caller's tokens. `key` is the SHA-256 hex digest of the raw token (matches what `npm token revoke` sends back); `token` is the 6-char preview surfaced when the original value isn't recoverable, which is what verdaccio does for the same reason.
- `DELETE /-/npm/v1/tokens/token/:key` — `npm token revoke`. Ownership-gated: 401 anonymous, 403 cross-user, 404 unknown key. Persists through the SQLite store so a restart can't resurrect a revoked token.
- `DELETE /-/user/token/:tok` — `npm logout`. Looks up by raw token, revokes by hash; same gating as the listing-side revoke.

`TokenStore` gains `find_by_key`, `list_for_user`, `revoke_by_key`, and `revoke_by_raw`; `publish::now_iso` is split so token timestamps render in the same ISO-8601 shape as `time.modified`.
2026-05-28 02:42:56 +02:00
kimulaco 2a0032edc0 fix(pacquet/workspace): support negated workspace package globs (#11961)
Fixes #11949.

Pacquet fails with `pacquet_workspace::invalid_glob` when `pnpm-workspace.yaml` uses negated `packages` patterns such as `!**/tools/*`.

The failure occurs because `wax` only supports `!` within character classes (e.g., `[!a]`), not as a leading glob negation.

This change separates negated patterns from include patterns and applies them via `wax`'s ignore path. It also preserves pnpm's `!/foo` behavior, which is a no-op for relative workspace paths.

This PR keeps the changes minimal and local to workspace project discovery, avoiding unnecessary refactoring or moving logic into shared crates.

Added workspace tests covering both `!libs/**` exclusion and the `!/libs/**` no-op case.
2026-05-28 00:20:56 +00:00
Zoltan Kochan 87136edaa3 fix(registry): verify tarball SHA on publish (#11976)
Closes #11975.

`pnpm-registry`'s `PUT /:pkg` endpoint accepted tarballs without verifying that their bytes matched the integrity declared in the packument. A buggy or partial-upload client could land a tarball whose hash didn't match `dist.integrity` / `dist.shasum`, and a malicious client could deliberately decouple the advertised hash from the bytes — both classes of silent-corruption bug that only surface when downstream `pnpm install` later fails with `EINTEGRITY`.

This change hashes every attachment before any I/O happens and rejects mismatches up-front so the bad bytes never reach the cache. The decode + hash + write runs in one streaming pass so the full decoded payload never lives in memory at once.

## What lands

- `stream_decode_verify_and_write` in `publish.rs` — pulls base64 chunks out of `base64::read::DecoderReader`, feeds each 64 KiB chunk to an ssri `IntegrityChecker` (SHA-512), an optional `IntegrityOpts(Sha1)` for legacy shasum, and the on-disk tmp file in lockstep. Verifies declared `length`, integrity, and shasum at the end; any failure removes the tmp file before returning.
- All failure modes surface as `400 BAD_REQUEST` with an `EINTEGRITY:` prefix in the body so pnpm / npm clients can recognize the error code.
- `extract_attachments` no longer base64-decodes eagerly; it returns `PendingAttachment { filename, data, declared_length }` and lets the streaming path consume `data` directly.
- `PackageName::parse_tarball_name` returns `(canonical, version)` so the publish handler can pull the matching `versions[v].dist` block out of the body. `canonicalize_tarball_name` becomes a thin wrapper.
- `Cache::reserve_tarball_paths` + `finalize_tarball_slot` expose a tmp/final path pair so the publish handler can do the actual write inside `tokio::task::spawn_blocking` (sync `std::fs`) and finalize via async `tokio::fs::rename` afterward.
- `publish_package` calls `stream_decode_verify_and_write` *before* any tarball lands at its final path. On any failure it removes every in-progress tmp file so a rejected publish leaves no on-disk artifact.

## Memory win

On a 100 MiB tarball publish, the old flow held the full payload in three places simultaneously (HTTP body Bytes + serde_json owned base64 String + decoded `Vec<u8>`). The streaming flow drops the decoded `Vec<u8>` entirely — only the base64 string and a 64 KiB working buffer remain. Roughly 100 MiB of peak heap saved per concurrent publish.
2026-05-28 02:20:25 +02:00
Zoltan Kochan f5490def66 perf(pacquet/package-manager): treat unsupported settings as no-opinion in optimistic-repeat-install (#12005)
The `state.settings == current` check compared every field on
`WorkspaceStateSettings`, including settings pacquet doesn't yet
implement (`peersSuffixMaxLength`, `dedupeDirectDeps`, `packageExtensions`,
…). Pnpm's defaults populate those fields when it writes the state,
while pacquet's `current_settings` leaves them `None`, so any
cross-package-manager scenario (pnpm wrote the workspace state,
pacquet runs next) rejected the fast path on iter 1 and fell into
the slower frozen no-op short-circuit. That's the 9.09× `babylon ×
lockfile+node_modules` regression in pnpm/pnpm#11992.

Switch `settings_match` to compare only the fields `current_settings`
actively writes. Mirrors pnpm's
[`Object.entries(workspaceState.settings)`](https://github.com/pnpm/pnpm/blob/72d997cc34/deps/status/src/checkDepsStatus.ts) walk: pnpm iterates the
settings present in the state, which by symmetry are the settings
the writer cared about. Pacquet's `current_settings` is the symmetric
"settings pacquet cares about" set, so comparing against it is the
natural way to honour the same contract.

Special-case `allowBuilds`: pnpm writes `Some({})` for an empty
allow-list, pacquet writes `None` — both mean "no opinion," so treat
them as equivalent (mirroring pnpm's `opts.allowBuilds ?? {}` coercion
on the read side).

The settings *not* compared are tracked at pnpm/pnpm#12009 with a
matching skip-list comment in code; each one drops out of the skip
list as it's ported end-to-end (yaml plumbing → `Config` field →
real install consumer → joined into `current_settings`). Two extra
groups (`minimumReleaseAge*`, `trustPolicy*`) hitch a ride: pacquet
*does* consume them during install, but they aren't surfaced through
the workspace-state crate yet. And two stay outside the comparison
permanently — `catalogs` (pnpm itself always ignores) and
`workspacePackagePatterns` (covered via `WorkspaceManifest.packages`
from `pnpm-workspace.yaml`).

End-to-end on `vltpkg/benchmarks/fixtures/babylon`, after pnpm has
written the workspace-state file:

  - before fix: pacquet iter-1 falls through to the frozen no-op
    short-circuit (~531 ms locally, ~9× pnpm on vlt CI).
  - after fix:  pacquet iter-1 fires the optimistic fast path
    (~96 ms locally, faster than pnpm's own warm fast path at
    ~687 ms — same 7-8× lead as iter-2+).

The `returns_skipped_when_unported_pnpm_settings_present` test that
locked in the conservative posture is replaced with
`returns_up_to_date_when_state_carries_unported_pnpm_settings`,
which exercises every #12009 setting plus `catalogs` and
`workspacePackagePatterns` together. A second new test,
`returns_up_to_date_when_state_has_empty_allow_builds_and_current_has_none`,
locks in the `allowBuilds` empty-vs-`None` coercion.

Closes pnpm/pnpm#11992.
Refs: pnpm/pnpm#12009
2026-05-28 02:03:29 +02:00
Zoltan Kochan 0cefccf158 feat(registry): persist pnpr users and tokens to disk (#11977)
* feat(registry): persist pnpr users and tokens to disk

Backs UserStore with a verdaccio-shaped htpasswd file (bcrypt $2y$
hashes, atomically rewritten on every adduser) and TokenStore with a
SQLite database that stores SHA-256 token hashes plus the per-record
fields the upcoming /-/npm/v1/tokens surface will need (created_at,
last_used_at, readonly, cidr_whitelist).

Configuration mirrors verdaccio's auth.htpasswd.{file,max_users}
under the existing YAML schema; tokens default to a tokens.db
sibling of htpasswd, overridable via auth.tokens.file. max_users=-1
disables registration end-to-end. Both files are written via
tmp+rename and loaded eagerly on startup so a malformed htpasswd
fails fast rather than booting with a silent empty user list.

Closes #11974.

* fix(registry): use OS CSPRNG, satisfy dylint + rustdoc

- TokenStore's per-process secret now comes from getrandom (OS-backed
  CSPRNG) instead of time/pid/stack address. Tokens are derived from
  this secret + a per-issue nonce, so weak entropy was making mint
  outputs guessable to an attacker who could bound those inputs.
- Reorder derives on AuthConfig / HtpasswdConfig / TokensConfig /
  MaxUsers to satisfy perfectionist::derive-ordering (prefix-then-
  alphabetical: Debug, Default first, then the rest).
- Re-export auth::identify so the rustdoc link from the now-public
  UserStore::verify resolves; rustdoc::private-intra-doc-links no
  longer fails the workspace doc build.
- Drop the inaccurate "+inf" mention from MaxUsers' doc — serde-saphyr
  treats +inf as a float and can't deserialize it into i64, so the
  only way to get Unlimited is to omit max_users.
2026-05-28 01:30:42 +02:00
Zoltan Kochan a33c4bfcb0 perf: skip resolution when only pnpm-lock.yaml is missing (pnpm + pacquet) (#12004)
* perf: skip resolution when only pnpm-lock.yaml is missing

When pnpm-lock.yaml is absent but node_modules/.pnpm/lock.yaml exists and still
satisfies the manifest, reuse the materialized snapshot to regenerate the
wanted lockfile instead of walking the registry to rebuild it. Closes the
cache+node_modules variation gap in the vlt.sh benchmarks for the pnpm CLI
side; the pacquet port is tracked separately at #11993.

`--frozen-lockfile` still fails when pnpm-lock.yaml is absent: the regenerated
file must be committed, so failing loudly is the correct behavior for CI.

* perf(pacquet): port the cache+node_modules shortcut

When `pnpm-lock.yaml` is absent but `node_modules/.pnpm/lock.yaml` exists
and still satisfies the manifest, synthesize the wanted lockfile from the
materialized snapshot and take the frozen-install path. The install skips
resolution and regenerates `pnpm-lock.yaml` from the synthesized object.

Mirrors the pnpm-side change at 8a2146b7be (#12004). The synthesis path
preserves CI semantics: `--frozen-lockfile` still errors with
`NoLockfile` when `pnpm-lock.yaml` is missing, because the regenerated
file must be committed.

For workspace installs (where `pnpm-workspace.yaml` is present),
`optimistic_repeat_install` pre-empts the install with "Already up to
date" before the synthesis can fire — pnpm's `checkDepsStatus` has the
same gap. That's a separate parity fix; the integration test removes the
workspace-state file to exercise the dispatch path the synthesis lives
in. Real-world single-project installs hit the
`wanted lockfile missing` gate at `optimistic_repeat_install.rs:149`
directly and reach the synthesis without extra setup.

* style(pacquet): apply rustfmt

* refactor: inline lockfile-emptiness check instead of adding a derived flag
2026-05-28 00:47:45 +02:00
Philip Roberts c94b4f89c7 fix: publish with default access (#11991)
* fix: preserve default publish access

* chore(publish): add changeset
2026-05-28 00:39:41 +02:00
Zoltan Kochan 72d997cc34 chore(release): 11.4.0 (#11989) v11.4.0 2026-05-27 15:15:01 +02:00
Zoltan Kochan b73908f088 chore: update pnpm-lock.yaml (#11897) 2026-05-27 13:00:13 +02:00
Zoltan Kochan aa6149df65 fix: fail by default when a tarball does not match the locked integrity (#11968)
`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`.
2026-05-27 12:46:16 +02:00
Zoltan Kochan c12681f68d docs(registry): flesh out @pnpm/pnpr README (#11972)
* docs(registry): flesh out @pnpm/pnpr README

Document install, default behavior, CLI flags, RUST_LOG, and a minimal
verdaccio-shaped YAML config example.

* docs(registry): reword pnpr intro

Drop the "runs locally / verdaccio-like" framing — pnpr is hostable and
not scoped to a local-dev role.

* chore(cspell): add packuments, refetched
2026-05-27 00:15:32 +02:00
f03dc2d15d refactor(registry): adopt verdaccio-shaped YAML config (#11970)
Reshape pnpm-registry's Config to match verdaccio's `config.yaml` schema (storage, uplinks, packages) so the same file can drive either server. The previous Config exposed a single `upstream: Option<String>` resolved at startup; this replaces it with named uplinks plus per-package `proxy:` rules walked in declared order — same semantics as verdaccio, minus the surface pnpm-registry does not implement (auth, web, plugins, middlewares, logs routing, secret), which are accepted and ignored.

Highlights:

  * `Config { listen, public_url, storage, uplinks, packages, packument_ttl }` with `UplinkConfig { url }` and `PackageAccess { access, publish, unpublish, proxy }`. `packages` is an `IndexMap` walked in declared order, first-match-wins: the first pattern matching a request is the rule that applies, and if that rule has no `proxy:` the package is storage-only (resolution returns `None` instead of falling through to a later catch-all). That makes the bundled `@private/*` / `@pnpm.e2e/needs-auth` / unscoped-fixture rules behave the way the YAML says they should.
  * `Config::from_yaml(path, ...)` loads via `serde-saphyr` and resolves a relative `storage:` against the config file's parent. The verdaccio-only sections in the YAML are skipped silently so `registry-mock`'s upstream `config.yaml` parses untouched.
  * `DEFAULT_CONFIG_YAML` — the bundled file mirrored from `@pnpm/registry-mock` — is `include_str!`-ed and re-exported from `lib.rs` so other crates (tests, benchmarks, future embedders) can use the same defaults without reading from disk. `Config::from_default_yaml(base_dir, ...)` parses it.
  * CLI: `-c` / `--config <path>` overrides the bundled default. `--storage` survives as a runtime override (handy for tests without a custom YAML); `--upstream` and `--static` are gone because the YAML now drives both. `--packument-ttl-secs` is optional — the loaded config's value wins when the flag is not supplied. The mock orchestrator at `pacquet/tasks/registry-mock` drops its `--upstream` flag — that command spawns the locally-built binary so it tracks this PR's source directly. The jest harness at `__utils__/jest-config/with-registry/globalSetup.js` keeps `--upstream` for now because CI installs `@pnpm/pnpr` from npm; the flag will be dropped in the same PR that bumps `@pnpm/pnpr` to a build that lacks it.
  * `server.rs` pre-builds one `Upstream` per declared uplink at router construction (keyed by name, in an `IndexMap`) and resolves the right client per request via `Config::resolve_uplink`. No per-request `ThrottledClient` allocations.

Existing tests are kept working by retaining the `Config::proxy` / `Config::static_serve` constructors and switching the test helper from `config.upstream = ...` to mutating
`config.uplinks["npmjs"].url`. All 107 tests in `pnpm-registry` pass (55 unit + 26 + 9 + 17 integration).

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * YAML-based registry configuration with package-level routing, multiple uplinks, auth and audit middleware
  * New --config option to load custom registry configs; optional storage override and packument TTL setting

* **Chores**
  * Default registry now uses the bundled configuration (web UI disabled by default)
  * Configuration refactor to support Verdaccio-style routing patterns and per-package access/publish rules

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-26 23:52:37 +02:00
Zoltan Kochan e55f4b5efd fix: require integrity for tarball-shaped lockfile resolutions (#11966)
* fix(lockfile.utils): require integrity for tarball-shaped lockfile resolutions

A tampered lockfile that strips the `integrity` field from a tarball
resolution let the worker download the URL contents and mint a fresh
integrity from the unverified bytes, so an attacker who could also
serve content at the referenced URL would install a tampered package
without any error — including under `--frozen-lockfile`. pnpm now
rejects such entries at lockfile-read time with
`ERR_PNPM_MISSING_TARBALL_INTEGRITY`, matching pacquet's existing
`pacquet_package_manager::missing_tarball_integrity` guard.

* test(lockfile.utils): drop redundant integrity-less snapshot that fails strict typecheck

* test(pacquet/package-manager): cover MissingTarballIntegrity rejection in snapshot_cache_key

Match the upstream guard landed alongside pnpm/pnpm#11966
(`lockfile/utils/src/pkgSnapshotToResolution.ts`) with a test on
the pacquet side: a `LockfileResolution::Tarball` with `integrity:
None` — what a tampered lockfile looks like — must short-circuit
the warm-batch cache-key derivation by surfacing
`InstallPackageBySnapshotError::MissingTarballIntegrity`. The
structural guard already existed but had no negative test.

* fix(lockfile.utils): exempt git-hosted and file: tarballs from the integrity guard

The strict guard added in the parent commit broke pnpm's own
`with-git-protocol-dep` and `with-non-package-dep` fixtures: the
install pipeline writes git-hosted tarball entries (codeload.github.com
URLs) to the lockfile without an `integrity:` line, because the commit
SHA in the URL is the integrity anchor — git's content-addressed model
binds the bytes to the commit, so a separate hash adds nothing.

Exempt git-hosted tarballs (detected either via the `gitHosted: true`
flag or a URL on the known git hosts, matching the URL fallback in
`toLockfileResolution`) and `file:` tarballs (local paths the user
already controls). The strict check still fires for any other remote
tarball — which is where the AutoFyn-reported vector actually
manifests.

Also export `isGitHostedTarballUrl` from `toLockfileResolution.ts` so
the URL fallback can be shared rather than duplicated.

* test(pacquet/package-manager): trim doc comment to the contract-level intent

Per the repo convention that tests are documentation, the test name
and body already cover what's being asserted; the prior comment
duplicated that. Keep only the non-obvious why: why this guard exists
at the cache-key site at all (warm-batch short-circuit) when the
install-side check also rejects the same input.
2026-05-26 23:15:03 +02:00
Zoltan Kochan 90d1ce6b60 fix(git-fetcher): reject non-SHA commit values before invoking git (#11967)
`fetching/git-fetcher/src/index.ts` passed the lockfile-controlled `resolution.commit` value straight to `git fetch --depth 1 origin <commit>` and `git checkout <commit>` with no `--` separator and no format validation. A malicious `pnpm-lock.yaml` could put a value such as `--upload-pack=touch /tmp/pwned` in `resolution.commit`; `git` parses anything starting with `-` as an option, and on SSH or local-file transports `--upload-pack` runs the supplied command as the user running `pnpm install`. HTTPS ignores `--upload-pack`, but the SSH/file paths are enough to reach code execution.

The fix validates `resolution.commit` against `/^[0-9a-f]{40}$/i` at the entry of the fetcher and throws `INVALID_GIT_COMMIT` otherwise. This is strictly stronger than adding a `--` separator — a validated value cannot start with `-` or contain shell-significant characters at all.

Pacquet's `pacquet-git-fetcher` crate shells out to `git` along the same code path (`pacquet/crates/git-fetcher/src/fetcher.rs`) and had the identical issue. Ported the same check there, with a new `GitFetcherError::InvalidCommit` variant carrying the `INVALID_GIT_COMMIT` diagnostic code.

Reported by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn).
2026-05-26 22:56:49 +02:00
Zoltan Kochan 89c2b52728 ci(pacquet): pin cargo-dylint to 6.0.0 to unblock CI (#11969)
trailofbits/dylint 6.0.1 (published 2026-05-26 17:51 UTC) ships
prebuilt cargo-binstall artifacts that bake in a path from the
dylint repo's own CI workspace:

    error: failed to get `dylint_driver` as a dependency of package
      `dylint_driver-nightly-2026-04-16-x86_64-unknown-linux-gnu`
    Caused by:
      failed to read `/home/runner/work/dylint/dylint/driver/Cargo.toml`
    Caused by:
      No such file or directory (os error 2)

Downstream runners don't have that workspace, so the driver bootstrap
fails before any lint runs and the Dylint job goes red on every PR.
6.0.0 (the version main was passing with 90 minutes earlier) is
unaffected. Pin both binaries until upstream cuts 6.0.2.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-26 20:59:03 +02:00
Zoltan Kochan f107b1e13a ci(test): install @pnpm/pnpr from npm instead of building locally (#11964)
The e2e/integration test harness spawns `pnpm-registry` as a faster
verdaccio replacement. CI used to install Rust and build the crate
from source on every test job — adding several minutes per platform.

`@pnpm/pnpr` now publishes the prebuilt binary to npm, and `pnpm install`
already pulls in the matching `@pnpm/pnpr.<platform>-<arch>` package
via optionalDependencies. The Jest globalSetup resolves that binary
through `@pnpm/pnpr/bin/pnpr`'s own module path (the wrapper carries
the platform packages as siblings in its `node_modules`, not on the
parent chain of this file).

- Add `@pnpm/pnpr` to `pnpm-workspace.yaml` catalog and depend on it
  from `@pnpm/jest-config`.
- Replace `resolvePnpmRegistryBin`'s `$CARGO_TARGET_DIR` lookup with
  `require.resolve` through the npm-installed wrapper. The
  `PNPM_REGISTRY_BIN` env var is still honored as an escape hatch for
  contributors who want to point at a locally-built Rust binary.
- Remove the "Install Rust toolchain" + "Build pnpm-registry" steps
  from `.github/workflows/test.yml`.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-26 19:33:11 +02:00
Zoltan Kochan 0f5f338a89 chore: add pnpr to cspell 2026-05-26 18:50:25 +02:00
KhảiandClaude f578281d2a feat(pacquet/config): --recursive, --workspace-concurrency (#11959)
* feat(pacquet/config): port --recursive and --workspace-concurrency settings

Mirror pnpm's `workspaceConcurrency` (a `.npmrc` / `pnpm-workspace.yaml`
/ `PNPM_CONFIG_WORKSPACE_CONCURRENCY` config-file key, default
`getDefaultWorkspaceConcurrency()`, resolved through
`getWorkspaceConcurrency`) and CLI-only `recursive` (`-r`) boolean.

- Add `Config::workspace_concurrency` (resolved via the existing
  `resolve_child_concurrency` port of `getWorkspaceConcurrency`) and
  `Config::recursive`, plus `default_workspace_concurrency`.
- Read `workspaceConcurrency` from workspace yaml, global config.yaml,
  and the `PNPM_CONFIG_*` env overlay; resolve negative offsets the
  same way `childConcurrency` does.
- Add the `--workspace-concurrency` install flag (overrides the
  config-resolved value) and the global `-r` / `--recursive` flag
  (sets `Config::recursive`, matching pnpm's CLI-only nature).

`workspaceConcurrency` is parsed and stored for config-surface parity;
pacquet's frozen install materializes the whole workspace in one shared
pass, so there is no per-project parallel loop for it to throttle yet
(same posture as `preferOffline`). `recursive` is likewise a surface
flag on install today, since install already spans the workspace.

* docs(pacquet/config): drop private intra-doc link to fix Doc CI

`default_workspace_concurrency`'s public doc linked the crate-private
`default_child_concurrency`, which `rustdoc -D rustdoc::private-intra-doc-links`
(implied by `-D warnings`) rejects. Use plain backticks instead.

* style(pacquet/cli): drop redundant comment on workspace_concurrency destructure bind

The sibling `offline: _` / `prefer_offline: _` binds carry no comment;
match them for consistency.

* test(pacquet/cli): cover the --workspace-concurrency override resolution

The inline `if let Some(value) = args.workspace_concurrency` override
body was only reachable when install ran *with* the flag, so the
flag-absent integration runs left it uncovered. Extract the resolution
into `InstallArgs::resolve_workspace_concurrency` and apply it
unconditionally at the dispatch (matching upstream's final
`getWorkspaceConcurrency` pass), so the substantive logic is covered by
fast unit tests (absent / positive / negative) and the call site is an
unconditional assignment the existing install tests already exercise.

* style(pacquet/cli): pipe-trait the workspace-concurrency test parse calls

Per review: rewrite the `InstallArgsHarness::try_parse_from([...])`
calls in the new workspace-concurrency tests as
`[...].pipe(InstallArgsHarness::try_parse_from)` so they read
left-to-right, matching the codebase's pipe-trait convention.

* docs(pacquet/config): drop inaccurate .npmrc source from workspaceConcurrency docs

workspace_concurrency is populated only from pnpm-workspace.yaml, global
config.yaml, and the PNPM_CONFIG_WORKSPACE_CONCURRENCY env overlay.
Config::current reads .npmrc but applies only the auth/network subset, so a
workspace-concurrency= entry in .npmrc never reaches the field. Align the doc
comments and PR summary with actual behavior, matching the sibling
childConcurrency docs.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-26 18:47:44 +02:00
Zoltan Kochan c42f69ffaf ci(pnpr): add manual release workflow that publishes @pnpm/pnpr to npm (#11963)
* ci(pnpm-registry): add manual release workflow that publishes to npm

Mirror pacquet's release pipeline for the pnpm-registry crate:

- New `Release pnpm-registry` GitHub workflow (manual `workflow_dispatch`
  with a version input) builds the `pnpm-registry` binary for six
  `<os>-<cpu>` matrix legs via `cross`, attests provenance, and uploads
  artifacts.
- The publish job patches `registry/npm/pnpm-registry/package.json`'s
  `version` field with the input, downloads the artifacts, runs
  `generate-packages.mjs` to produce per-platform packages, then
  publishes everything via `pnpm publish --provenance` using OIDC.
- The wrapper package is `pnpm-registry`; the per-platform binary
  packages are scoped `@pnpm/registry.<os>-<cpu>` and resolved by a
  Node shim under `bin/pnpm-registry`.

The CLI's `--version` output comes from `CARGO_PKG_VERSION` via clap's
derive `version` attribute, so the build leg patches the crate's
`Cargo.toml` `version` field rather than a hardcoded clap string.

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

* ci(pnpr): rename package to @pnpm/pnpr with @pnpm/pnpr.<os>-<cpu> binaries

Shorter, scoped name that reads naturally on npm: `@pnpm/pnpr` for the
wrapper, `@pnpm/pnpr.<os>-<cpu>` for the six per-platform binary
packages (mirroring `@pacquet/<os>-<cpu>` under a different scope).

- Move `registry/npm/pnpm-registry/` to `registry/npm/pnpr/` and rename
  the JS shim and `bin` entry to `pnpr`.
- Update `generate-packages.mjs` so `BIN_NAME = pnpr` (used for both
  the binary file inside each platform package and the autogenerated
  package dir) and the scope/prefix produce `@pnpm/pnpr.<os>-<cpu>`.
- Rename the workflow file to `pnpr-release-to-npm.yml` and rename
  the built `pnpm-registry` binary to `pnpr-<code-target>` at archive
  time, so `generate-packages.mjs` finds it at `REPO_ROOT/pnpr-<plat>-<arch>`.
- The Rust crate name stays `pnpm-registry` (still what `cross build -p`
  targets); only the npm-publishing layer is renamed.

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

* fix(pnpr): propagate signal exits through the Node shim

`spawnSync` returns `result.status === null` when the child terminates
via a signal. Assigning that to `process.exitCode` makes the parent
exit 0 and masks the signal — bad for a long-running server where
the operator is most likely to kill it with SIGINT/SIGTERM. Re-raise
the signal so the parent terminates the same way, falling back to a
non-zero exit code if for some reason we can't.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-26 17:54:58 +02:00