Commit Graph

11696 Commits

Author SHA1 Message Date
qybaihe
37669c200d fix: avoid workspace state parse crashes (#12094)
* fix: avoid workspace state parse crashes

* fix(workspace-state): write workspace state atomically

Port the atomic-write half of the pnpm fix for #12020 to pacquet.
pacquet's install/add/update/remove all call update_workspace_state,
and the on-disk file is shared with the pnpm CLI, so a non-atomic
fs::write could leave a torn file that a concurrent `pnpm run` reads
and crashes on. Write to a temp file in the same directory and rename
it into place, mirroring pnpm's switch to write-file-atomic.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-01 15:57:41 +02:00
morning-verlu
118e9be809 fix: set user agent in headless lifecycle scripts (#12092)
Co-authored-by: morning-verlu <258725120+morning-verlu@users.noreply.github.com>
2026-06-01 15:06:24 +02:00
Abdullah Alaqeel
719cc21c6f fix(audit): prune path traversal (#12087)
* fix(audit): prune path traversal

* fix(audit): memoize placeholder set before recursion to preserve cycle reachability

The reachable-vulnerabilities getter returned a non-memoized empty Set for
back-edges, causing incomplete results for nodes in dependency cycles. Memoize
the result Set immediately so the same mutable placeholder is returned for
back-edges and filled as recursion unwinds.

* fix(audit): only memoize acyclic reachability subtrees

The placeholder-before-recursion approach only made the SCC entry node's
reachable set correct; non-entry cycle members were memoized with an
under-approximated set, dropping valid audit paths reached through them.
Cache a node's reachable vulnerabilities only when no descendant back-edges
to an ancestor; recompute cycle-touching nodes per query.

---------

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

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

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

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

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

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

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

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

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

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

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

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

Written by an agent (Claude Code, claude-opus-4-8).
2026-06-01 15:01:22 +02:00
Khải
577a90f819 feat(pacquet): --resume-from, --report-summary (#12093)
* feat(cli): port recursive run with --resume-from and --report-summary

Port pnpm's `pnpm run -r` (recursive run) to pacquet, including the
`--resume-from` and `--report-summary` flags, which previously existed
only in the TypeScript CLI.

- `pacquet -r run <script>` now runs the script in every workspace
  project in topological order, mirroring pnpm's runRecursive: discover
  projects, build the inter-project dependency graph, sort it into
  chunks via graph_sequencer (the port of sortProjects), and execute.
- `--resume-from <pkg>` drops every chunk before the one containing
  <pkg>, mirroring getResumedPackageChunks; an unknown package fails
  with ERR_PNPM_RESUME_FROM_NOT_FOUND.
- `--report-summary` writes pnpm-exec-summary.json with the per-package
  status (queued/running/passed/skipped/failure) and duration, nested
  under an executionStatus key, mirroring writeRecursiveSummary.
- `--no-bail` keeps running after a failure (recursive runs bail by
  default). Failures surface ERR_PNPM_RECURSIVE_FAIL, or
  ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL when bailing; a run that matches no
  script fails with ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT.

A new executor helper, execute_shell_with_status, returns the child's
exit status so per-package pass/fail can be recorded; execute_shell is
unchanged.

Not yet ported (noted in the module): --no-sort, --reverse,
--workspace-concurrency parallelism, --filter narrowing of the selected
set, and the RegExp script selector. The selected set is every workspace
project, matching pacquet's currently-unfiltered install.

Integration tests port the upstream resume-from and report-summary
cases from exec/commands/test/runRecursive.ts.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): gate recursive-run tests to unix and add macro trailing commas

Fix two CI failures on the recursive-run integration tests:

- Dylint (`perfectionist::macro_trailing_comma`): add the trailing comma
  to the four multi-line `assert!` invocations.
- Lint and Test (windows-latest): the shared helpers are used only by
  the Unix-gated tests, so on Windows they tripped `dead_code` under
  `-D warnings`. Gate the whole file with `#![cfg(unix)]` (the build
  scripts run through pacquet's `sh -c` executor anyway), matching the
  single-package `run` tests.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): cover recursive-run bail summary and no-script branches

Fill the two coverage holes in the recursive-run handler:

- bail + report-summary: the first failing script writes the summary,
  then aborts with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL; a package that
  sorts after the failure stays `queued`.
- no-script: a recursive run for a script no package defines fails with
  ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT, and `--if-present` turns that into a
  clean no-op.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): cover the bail path without --report-summary

The bail tests always passed --report-summary, leaving the
report-summary-off side of the bail block (recursive.rs:136) uncovered.
Add a test for a failing script with bail on and no --report-summary:
it still fails with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL and writes no
summary file. Verified with cargo llvm-cov that recursive.rs now has no
missing lines.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* fix(cli): run each recursive-run script from its own package root

Recursive run spawned every package's script via `sh -c` without
setting the working directory, so scripts ran in pacquet's process CWD
(the workspace root) instead of their own package root. That breaks
scripts relying on relative paths and diverges from pnpm, whose
`runLifecycleHook` runs with `pkgRoot` as the working directory.

- Give `execute_shell_with_status` a `current_dir` argument (factored
  through a private `spawn_shell` helper); `execute_shell` keeps its
  inherited-CWD behavior, so its callers are unchanged.
- Pass each package's root as the script's working directory.
- Make the marker-based recursive-run tests cwd-sensitive: scripts now
  write a relative `ran.txt`, and the tests assert it lands under each
  package root (and not at the workspace root), so a wrong-CWD
  regression fails the suite.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 12:48:10 +02:00
Zoltan Kochan
54d2b57000 feat(pnpr): server-accelerated installs via pnprServer (endpoints + client + CLI) (#12077)
## What

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

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

## How it works

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

## Pieces

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

## Design notes

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

## Tests

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

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

## Deferred

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

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

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

Close #12079

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

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

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

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

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

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

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

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

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

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

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

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

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

State that pnpr and pnpm-agent are not affiliated with or endorsed by
npm, Inc., GitHub, or Microsoft, and that "npm" is used only to describe
registry-protocol compatibility. Also add a License section to the
published @pnpm/pnpr npm wrapper README.
2026-05-30 22:51:14 +02:00
Zoltan Kochan
394ee27e09 feat(tarball): support remote https-tarball direct dependencies (#12076) 2026-05-30 11:42:53 +02:00
dependabot[bot]
e2f801e4ec chore(cargo): bump tar from 0.4.45 to 0.4.46 (#12074)
Bumps [tar](https://github.com/composefs/tar-rs) from 0.4.45 to 0.4.46.
- [Release notes](https://github.com/composefs/tar-rs/releases)
- [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 0.4.46
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:41:58 +02:00
Khải
13b1b9aaa2 chore(rust/dylint): upgrade perfectionist to 0.0.0-rc.17 (#12070)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 20:58:31 +00:00
Zoltan Kochan
55ba310835 fix(pnpr): drop version floor on pnpr workspace deps so releases resolve (#12073)
The release workflow injects the dispatched version into pnpr's crate
version field. Because the root workspace declared the pnpr and
pnpr-fixtures path deps with a version = "0.0.1" floor, injecting any
version below 0.0.1 or a prerelease (e.g. 0.0.0-26052901) made cargo
fail to resolve pacquet-testing-utils, which depends on pnpr via the
workspace.

None of these crates is published to crates.io (the binary ships to
npm), so the version floor served no purpose. Drop it to match every
pacquet workspace path dep, which carries no version requirement.
2026-05-29 21:56:14 +02:00
Zoltan Kochan
248223fa44 test(exe): fall back to copy when seeding sandbox binary across drives (#12072)
GitHub's Windows runners place the workspace (and node) on D: while
os.tmpdir() is on C:, so hardlinking process.execPath into the temp
sandbox fails with EXDEV. The seed binary's identity is irrelevant to
the assertions, so copy when the link would cross volumes.
2026-05-29 21:29:16 +02: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