Commit Graph

11684 Commits

Author SHA1 Message Date
dependabot[bot]
2034fc0ffb chore(deps): bump the github-actions group across 1 directory with 8 updates
Bumps the github-actions group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action](https://github.com/github/codeql-action) | `4.35.5` | `4.36.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [taiki-e/install-action](https://github.com/taiki-e/install-action) | `2.78.1` | `2.79.5` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.46.1` | `1.46.2` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.5` | `0.5.6` |



Updates `github/codeql-action` from 4.35.5 to 4.36.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](9e0d7b8d25...7211b7c807)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](4d04d5d948...d7f5e7f509)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](bcafcacb16...f9f3042f7e)

Updates `taiki-e/install-action` from 2.78.1 to 2.79.5
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](184183c240...6c1f7cf125)

Updates `crate-ci/typos` from 1.46.1 to 1.46.2
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](5374cbf686...aca895bf05)

Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](57e3a136b7...e79a6962e0)

Updates `zizmorcore/zizmor-action` from 0.5.5 to 0.5.6
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](a16621b09c...5f14fd08f7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.46.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: taiki-e/install-action
  dependency-version: 2.79.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 18:12:33 +00:00
Zoltan Kochan
c5d9d3a8f3 refactor(pnpr): rename pnpm-registry to pnpr (#12069)
* refactor(pnpr): rename pnpm-registry to pnpr

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

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

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

* style(pnpr): rustfmt import grouping after rename

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

* chore(pnpr): update pre-push path filter from registry to pnpr
2026-05-29 20:02:10 +02:00
dependabot[bot]
36e6a27066 chore(cargo): bump sysinfo from 0.39.1 to 0.39.2 (#12059)
Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.39.1 to 0.39.2.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.1...v0.39.2)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.2
  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 17:58:43 +02:00
Zoltan Kochan
4024f13741 chore: only run expensive Rust pre-push checks when pacquet or registry change (#12050) 2026-05-29 17:58:13 +02:00
dependabot[bot]
3db9dcdaf3 chore(cargo): bump serde_json from 1.0.149 to 1.0.150 (#12063)
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  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 17:53:33 +02:00
Zoltan Kochan
b741d91e67 chore(release): 11.5.0 (#12068) v11.5.0 2026-05-29 17:26:13 +02:00
dependabot[bot]
e56b126299 chore(cargo): bump getrandom from 0.3.4 to 0.4.2 (#12060)
Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.3.4 to 0.4.2.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.2)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.2
  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:38 +02:00
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
Khải
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 Garbutt
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 Setnik
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 Picado
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 Attilio
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 Alaqeel
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
Juan Picado
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