mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
37669c200d3fff2db20a032bc092ed7dc797be45
61 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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). |
||
|
|
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> |
||
|
|
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 |
||
|
|
394ee27e09 | feat(tarball): support remote https-tarball direct dependencies (#12076) | ||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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>
|
||
|
|
ad84fffd46 |
fix: reject path-traversal segments in dependency aliases (#11954)
* fix: reject path-traversal segments in dependency aliases A transitive registry package can use a dependency-alias key like `@x/../../../../../.git/hooks` to make `pnpm install` create a symlink outside the intended `node_modules` directory, since pnpm passes the alias straight into `path.join(modulesDir, alias)` without checking that the joined path stays inside `modulesDir`. Reject aliases that aren't a single `name` or `@scope/name` shape at manifest-read time (both the importer's manifest and every transitive package manifest) and re-check at the symlink layer as defense in depth. Mirror the fix in pacquet's deps-resolver. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(pacquet): use raw strings in alias validator tests for dylint Perfectionist's `prefer-raw-string` lint rejects the two backslash-escaped test inputs. --- Written by an agent (Claude Code, claude-opus-4-7). * refactor: tighten dependency-alias validator to validate-npm-package-name An alias is the directory name pnpm creates inside `node_modules`, so the only valid shapes are a single `name` or `@scope/name` consisting of URL-friendly characters with no leading `.` / `_`, and not equal to reserved names such as `node_modules`. That's the same `validForOldPackages` rule `parseWantedDependency` already applies to CLI-given names — the manifest-read path should match. Route both stacks through it so `.bin`, `.pnpm`, `node_modules`, `favicon.ico`, whitespace, and non-URL-friendly characters are all rejected alongside the path-traversal shapes the narrow validator caught. --- Written by an agent (Claude Code, claude-opus-4-7). * refactor: collapse symlink-layer assertion + path.join into safeJoinModulesDir The two-step pattern of "assert the alias stays in the dir" then "join the dir and the alias" left it possible for a caller to use the join without the assertion. Fold them into a single `safeJoinModulesDir` that returns the joined path and throws on escape, so the check is unmissable. --- Written by an agent (Claude Code, claude-opus-4-7). * test(symlink-dependency): cover the path-equals-dir guard branch The earlier tests only exercised the `!startsWith` branch with `'../sibling'` and `'@x/../../../etc'`. Add `''` and `'.'` as alias cases — both resolve to the modules dir itself and hit the `resolvedLink === resolvedDir` branch of `safeJoinModulesDir`. --- Written by an agent (Claude Code, claude-opus-4-7). |
||
|
|
1fb8a2d5d8 |
perf(pacquet): unlock no-op short-circuit + port abbreviated-modified verifier shortcut (#11931)
Two fixes that together unlock pnpm-parity on the `benchmarks.vlt.sh` `lockfile+node_modules` shape — the row where pacquet was 2-12× slower than pnpm on every fixture. ### 1. `fix(modules-yaml)`: normalise joined `virtualStoreDir` `read_modules_manifest` joins a stored relative `virtualStoreDir` with `modules_dir` to recover an absolute path, mirroring upstream's `path.join(modulesDir, modules.virtualStoreDir)`. Node's `path.join` normalises interior `..` segments; Rust's `PathBuf::join` does not. Stored values like `../../../Users/.../store/v11/links` came back as `<modules_dir>/../../../Users/.../links` — never byte-matched `Config::effective_virtual_store_dir()`, so the no-op short-circuit added in #11904 silently missed every install whose store sits outside the project (the default macOS / Linux setup). The accompanying refactor lifts `lexical_normalize` (already duplicated in `cmd-shim` and `store-dir`) into `pacquet-fs` so `modules-yaml` doesn't make it a third copy. ### 2. `perf(resolving-npm-resolver)`: port the missing verifier layers The npm resolution verifier walked a 4-layer fallback chain in upstream pnpm (abbreviated-modified shortcut → on-disk full-meta mirror → npm attestation endpoint → full packument fetch); pacquet only had the last two. The module's doc-comment explicitly noted "Phase 4 stubs the abbreviated-shortcut and on-disk-mirror layers (no cached fetcher / no mirror yet); Phase 5 ports `fetchFullMetadataCached.ts`…" — this is Phase 5. Result: a cold lockfile-verification pass now pays at most one *abbreviated* GET per name (small payload, decided by package-level `modified`) instead of a full-meta GET per name (hundreds of KB each). ## Bench 5-iteration cold-cache measured pass on `vltpkg/benchmarks/fixtures/svelte` (`pnpm-lock.yaml` + `node_modules` present, `~/.cache/pnpm` and store wiped before each run), 10-core M-series Mac: | | pnpm | pacquet@main | this PR | |-------------|------:|-------------:|--------:| | wall time | 0.54 s | 2.16 s | 0.71 s | 3.0× faster on the `lockfile+node_modules` row. |
||
|
|
e52e4fce63 |
feat(pacquet): port detect-libc to Rust and replace ad-hoc libc detection in graph-hasher (#11921)
* refactor(graph-hasher): replace ad-hoc libc detection with pacquet-detect-libc Extract libc detection into a new `pacquet-detect-libc` crate ported from the upstream `detect-libc` JS package, replacing the limited ad-hoc `detect_host_libc()` in graph-hasher. Detection uses a three-tier fallback (ELF header → filesystem → command) that avoids spawning processes in the common case and works in slim containers where getconf or ldd may not be present. The command step runs getconf and ldd --version as separate subprocesses to avoid stream pollution between the two, with ldd only invoked when getconf fails. * fix(detect-libc): harden ELF parser, UTF-8 decoding, test cfg, and imports - Use checked arithmetic (checked_add/checked_mul) in elf_interpreter to return None on overflow instead of panicking on malformed headers - Use from_utf8_lossy for /usr/bin/ldd content so non-UTF-8 bytes don't skip the filesystem detection path - Gate detect_integration_host test with #[cfg(target_os = "linux")] so it doesn't fail on non-Linux platforms - Replace use super::* with explicit imports in command tests * fix(detect-libc): use from_utf8_lossy for command output, fix lints and tests |
||
|
|
d579e6cbb5 |
perf(pacquet): trim install-phase syscalls and allocations (#11864)
* perf(fs,package-manager): striped CAS lock + skip pre-flight stat on fresh-target imports Two install-phase syscall trims: - `cas_write_lock` swaps the per-path `DashMap<PathBuf, Arc<Mutex<()>>>` for 256 static `Mutex<()>` stripes keyed by hashed path. Every CAFS write previously paid one `PathBuf::to_path_buf` allocation, a `DashMap` shard write lock, plus an `Arc<Mutex<()>>` slot allocation even though contention was vanishingly rare. Striping keeps the writer/verifier coordination the per-path mutex provided while removing those per-call costs. With 256 stripes and ~10 rayon workers the false-sharing probability per pair is ~4%, and the guarded body (one `O_CREAT|O_EXCL` open + `write_all` of a tar entry) is microseconds long. - `import_indexed_dir::populate_dir` now calls a new `import_into_fresh_target` instead of `link_file`. `populate_dir` only ever runs against a directory it just `mkdir`'d, so the `fs::metadata` pre-flight `link_file` performs to protect the Copy-method overwrite contract is wasted — every call is `NotFound` in practice and the EEXIST surface from the import syscall is the only collision signal we need. Saves ~170k `stat` syscalls per clean install on the alotta-files fixture. `link_file` still exists with the original semantics for any caller that genuinely doesn't know whether the target is fresh. On the 3343-package alotta-files fixture against the verdaccio mock, clean-install wall time goes from ~28s to ~19-22s on the local 10-core machine — roughly closing the gap to pnpm (~20s) for that scenario. Refs #11857, #11851. * perf(store-dir): trim per-CAS-file allocations on the hot write path Two micro-optimisations in `cas_file_path`, the helper every CAFS write goes through: - `cas_file_path` no longer `format!`s the sha-512 digest into a fresh `String`. Sha-512 is always 64 bytes / 128 hex chars, so render the hex into a stack buffer and slice it into the `file_path_by_hex_str` call instead. One heap allocation per file shaved off — ~170k on the alotta-files clean install. - The repeated `self.v11().join("files")` rebuild used to walk two `PathBuf::join`s per call. Memoise the result behind a `OnceLock` on `StoreDir` (`cached_files_dir`) so `file_path_by_head_tail` borrows it without re-joining. Race-free initialisation across rayon workers, one allocation per process instead of one per file. Refs #11857. * docs(pacquet): address CodeRabbit nits - Refresh `import_indexed_dir` doc comments so they name `import_into_fresh_target()` (the actual materialization helper after the fresh-target split) instead of `link_file()`. - Add a const assertion that `NUM_CAS_LOCK_STRIPES` stays a power of two, since `cas_write_lock` uses `& (NUM_CAS_LOCK_STRIPES - 1)` as the stripe selector. * docs: forbid past-implementation history in comments - Extend AGENTS.md Comments rules: comments must describe the current contract, not what the code replaced. Phrasings like "used to", "previously", "the original X", or parentheticals naming a removed type belong in `git log`. - Apply the rule to `cas_write_lock`'s doc, which previously framed itself in terms of the removed `DashMap<PathBuf, Arc<Mutex<()>>>` shape. |
||
|
|
ac299aa0e5 |
fix(pacquet,package-manager): walk every workspace project in fresh-resolve install (#11905)
* fix(package-manager): walk every workspace project in fresh-resolve install The fresh-resolve install path (no `--frozen-lockfile`, no usable lockfile) only resolved the workspace root manifest, so sibling workspace projects' own dependencies never landed in the lockfile or on disk. Re-run `resolve_importer` per importer with shared install caches (`meta_cache`, `fetch_locker`, `picked_manifest_cache`), merge the per-importer graphs, and emit one `importers[<id>]` entry per project. Mirrors upstream's [`resolveRootDependencies`](https://github.com/pnpm/pnpm/blob/3422cecfd3/installing/deps-resolver/src/resolveDependencies.ts#L327-L437) iteration shape — one shared resolution context, per-importer direct-deps slices. Per-importer `link_bins` so each project gets its own `node_modules/.bin`. GVS `register_project` now loops every importer key the freshly-built lockfile carries, mirroring the frozen path. `importer_dep_version` and `snapshot_dep_ref` learned a `link:` short-circuit so workspace-sibling edges emit `ImporterDepVersion::Link` / `SnapshotDepRef::Link` instead of falling through to the `name@version` parser. Cross-importer `TreeCtx` sharing (full upstream parity: one resolution context with per-importer hoist loops) is deferred — each `resolve_importer` call still has its own context. Network-side caches still amortize packument fetches and JSON parsing across importers; only per-resolve semver matching duplicates. Closes #11901. * fix(workspace): drop trailing comma on single-line assert_eq! for Perfectionist lint * fix(package-manager): register only the workspace root with the store, matching pnpm Pacquet was looping `register_project` over every importer in both the frozen-lockfile and fresh-lockfile branches, but upstream pnpm calls `registerProject(opts.storeDir, opts.lockfileDir)` exactly once per install against the workspace root — store prune walks the workspace's `node_modules/.pnpm/` to find every installed package, so one registry entry per workspace is enough. Consolidate to a single call near the start of `Install::run`, matching pnpm's `getContext` ordering at <https://github.com/pnpm/pnpm/blob/d8a79a9c30/installing/context/src/index.ts#L128>. Also port two upstream-derived tests that the multi-importer rewrite of `compute_corrected_optional` and the per-importer link rendering were previously missing direct coverage for: - `multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches_via_prod` ports the spirit of pnpm's [`pruneSharedLockfile`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/lockfile/pruner/src/index.ts#L17) cross-importer pooling: a depPath reached via a non-optional path from any importer ends up `optional: false` even when another importer reaches it only via an optional path. - `workspace_sibling_link_renders_per_importer_with_link_ref` exercises the multi-importer `workspace:`-link case — importer A depends on importer B via a `link:`-resolved depPath, both render their own `importers[<id>]` entries, and the link node stays out of `packages:` / `snapshots:`. * fix(package-manager): skip undeclared aliases from pruner BFS seeds Addresses CodeRabbit's review on PR #11905. Pacquet's resolver hoists auto-installed peers into `direct_dependencies_by_alias` even when they aren't in the importer's manifest (see `resolve_importer::direct.extend(...)` after each `hoist_peers` call). `build_importer` correctly excludes those undeclared aliases from the importer's lockfile entry, but `compute_corrected_optional` was seeding the pruner BFS from the full `direct_dependencies_by_alias` and defaulting unknown aliases to `DependencyGroup::Prod`. That diverges from upstream's [`pruneSharedLockfile`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/lockfile/pruner/src/index.ts#L27-L29), which seeds purely from `lockfile.importers[*].{dev,optional,}dependencies` — i.e., from the same set `build_importer` writes. The mismatch forced auto-peers reachable only via an optional parent's chain to `optional: false`, leaking them into non-optional installs. Skip aliases not in the manifest when seeding. The new test `auto_installed_peer_not_declared_in_manifest_is_skipped_from_pruner_seeds` pins the corrected behavior — `peer-x` (auto-installed for an `optionalDependencies` parent) stays `optional: true`, matching pnpm. Verified the test fails against the pre-fix code. Also tightens the multi-importer integration test's lockfile assertion: scope the `hello-world-js-bin-parent` check to the `packages/a:` importer section instead of a global substring match, so the test proves the direct-dep entry — not just any mention in `packages:`. * fix(package-manager,store-dir): ensure store root exists before registering project CI failure: `fresh_install_honors_enable_global_virtual_store` started failing after the previous register_project consolidation. Two compounding bugs: 1. `register_project` now runs early in `Install::run`, before any install phase has materialized the store. With the test's relative `storeDir: ../pacquet-store` in `pnpm-workspace.yaml`, `config.store_dir.root()` ends up shaped like `<workspace>/../pacquet-store/v11` — a path that doesn't yet exist on disk. 2. `path_contains`'s "lexical fallback" wasn't actually lexical — it called `dunce::canonicalize`, and on failure (path doesn't exist) it kept the path verbatim and ran `starts_with`. So `<workspace>/../pacquet-store/v11`.starts_with(`<workspace>`) returned true, the early-return guard fired, and the call silently skipped without writing the registry entry. Two-part fix matching upstream: - `Install::run` now calls `fs::create_dir_all(store_dir.root())` before `register_project`, mirroring pnpm's [`fs.mkdir(opts.storeDir, { recursive: true })`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/installing/context/src/index.ts#L125) call right before `registerProject`. Once the store exists, `canonicalize` succeeds and `path_contains` resolves both sides correctly. - `path_contains` now lexically normalizes `.` / `..` components when canonicalize fails. Matches upstream's `is-subdir` semantics (which uses `path.relative`, purely lexical). New test `path_contains_resolves_parent_components_when_paths_do_not_exist` pins the behavior; verified it fails against the pre-fix code. * style: cargo fmt * fix(package-manager,store-dir): satisfy Perfectionist lint and harden lexical_normalize Two issues: 1. The multi-line `assert!` in `multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches_via_prod` was missing its trailing comma after `cargo fmt` reformatted it from one-line to multi-line. Perfectionist's `macro-trailing-comma` rule (which CI enforces via Dylint) flagged it. Added the comma. 2. CodeRabbit pointed out that `lexical_normalize` silently dropped leading `..` components because `PathBuf::pop()` is a no-op when the path is empty. For the current `path_contains` callers (both inputs are absolute paths) this doesn't matter, but the helper is now a general-purpose utility and the bug would bite any future caller passing a relative path. Replaced the naive `out.pop()` with a match on the trailing component: - `Component::Normal(_)` → pop (real segment collapses with `..`) - `Component::RootDir | Prefix(_)` → drop the `..` (`/..` is `/` per POSIX) - else → push `..` (preserve leading `..` chain in relative paths) Matches Go's `path.Clean` semantics. New test `lexical_normalize_handles_parent_dir_corner_cases` pins all four corner cases. |
||
|
|
d8a79a9c30 |
feat(registry): add auth/dist-tag/publish endpoints + wire TS tests onto pnpm-registry (#11914)
Lands the pieces of the npm registry protocol that pnpm-registry was missing, and switches the TypeScript test harness off verdaccio onto pnpm-registry. `@pnpm/registry-mock` (the npm package) is untouched. ### Server-side additions (`registry/crates/pnpm-registry`) - `PUT /-/user/org.couchdb.user:<name>` — adduser / login, returns a Bearer token. In-memory user + token stores. - `PUT /:pkg` — publish (scoped + unscoped). Base64-decodes `_attachments`, merges into the existing packument, writes manifest + tarball atomically. 100 MiB body limit. - `GET /-/package/:pkg/dist-tags` + `PUT/DELETE /-/package/:pkg/dist-tags/:tag` — rewrites the on-disk packument so tag changes survive a restart. - `Authorization: Bearer` and `Authorization: Basic` both identify the caller. - Per-package access policy (wax glob patterns). Defaults mirror `@pnpm/registry-mock`'s `config.yaml`: `@private/*` and `@pnpm.e2e/needs-auth` require auth; everything else is anonymous read, authenticated write. Enforced on every packument / version-manifest / tarball GET and every write endpoint. ### TypeScript-test migration - `__utils__/jest-config/with-registry/globalSetup.js` keeps `prepare()` from `@pnpm/registry-mock` (still needed for the tempy storage path written into the runtime-config yaml — `getIntegrity` reads it from there) but spawns `pnpm-registry` instead of verdaccio. `addUser`, `addDistTag`, `getIntegrity`, `REGISTRY_MOCK_*` from registry-mock work as-is — they're plain npm-wire-protocol HTTP calls. - Binary lookup follows pacquet's pattern: `PNPM_REGISTRY_BIN` env override, then `target/release/pnpm-registry`, then `target/debug/pnpm-registry`. - CI test job (`.github/workflows/test.yml`) installs the Rust toolchain via the existing `./.github/actions/rustup` composite action and builds `pnpm-registry --release` before tests run. Per-platform — Linux and Windows in the matrix each build their own. |
||
|
|
add6c794f1 |
feat(registry): implement pnpm-registry server and adopt it in pacquet's test mock (#11898)
Creates a working pnpm-compatible npm registry server (verdaccio analogue, in Rust) — and replaces `@pnpm/registry-mock`'s Node + Verdaccio launcher in pacquet's test setup with the new binary, against `@pnpm/registry-mock`'s shipped storage.
### What `pnpm-registry` does
- **HTTP server** (axum + tower-http) with the three endpoints pnpm/npm clients need:
- `GET /<pkg>` — packument (`/{name}` and `/{scope}/{name}`)
- `GET /<pkg>/<version-or-tag>` — single-version manifest, resolves `dist-tags` and rewrites `dist.tarball` to point at this server
- `GET /<pkg>/-/<tarball>` — tarball, streamed
- **Two modes:**
- **Proxy** — fetches missing packuments/tarballs from a configurable upstream (defaults to `https://registry.npmjs.org`), caches to disk
- **Static** (`--static`) — serves the storage directory verbatim, 404s on cache miss
- **Verdaccio-shaped on-disk storage** (`<root>/<pkg>/package.json` + flat tarballs) — drop-in compatible with the storage `@pnpm/registry-mock` publishes
- **Tarball streaming** — cache hits stream off disk; cache misses tee upstream chunks into a temp file via an mpsc channel and forward them to the client at the same time, atomically renaming on success and abandoning on upstream error or client disconnect
- **Tuned HTTP client** — wraps `pacquet_network::ThrottledClient::new_for_installs()`, inheriting pnpm's tuned defaults (`User-Agent: pnpm`, HTTP/1.1, hickory DNS, connection-pool tuning, concurrency semaphore)
- **Gateway-style status mapping** — `is_timeout()` → 504, `is_connect()` → 503, everything else (incl. upstream 5xx) → 502. No proxy-side retry (the pnpm client already has `fetch-retries`; stacking retries would only multiply latency on real failures).
### What changed in pacquet
- `pacquet/tasks/registry-mock` now spawns `pnpm-registry` against `node_modules/@pnpm/registry-mock/registry/storage-cache` (proxy mode with `npmjs.org` upstream and a 1-year packument TTL — matching `@pnpm/registry-mock`'s `'**': proxy: npmjs` verdaccio config). No more Node, no more Verdaccio, no more `launch.mjs`, no more process-tree walk to kill child verdaccios.
- `@pnpm/registry-mock` stays as a devDep — only for the storage data it ships, not the launcher.
### Tests
- **36 pnpm-registry tests** (12 unit + 7 against `@pnpm/registry-mock` storage in static mode + 17 mockito-based proxy/cache/streaming): packument rewrite, version-manifest resolution, tarball streaming (large body, cache finalize, mid-stream upstream error, client disconnect mid-stream, concurrent fetches → one cache file), gateway status mapping (504/503/502), stale-cache fallback on upstream failure, TTL refresh, invalid-package-name 400, scoped vs unscoped routing.
- **Full pacquet test suite** (2043 tests) runs green against `pnpm-registry`-backed mock.
### CI
- `pacquet-ci.yml` and `pacquet-codecov.yml` path filters now include `registry/**` (so registry-only PRs trigger the workspace CI); typos checker covers `registry` too. The workflow name stays "Pacquet CI" but a header comment explains the intentional cross-stack scope.
- `just registry-mock launch` pre-builds with `cargo nextest run --no-run` (workspace-wide) so its fingerprint matches what `just test` will later need — without this, Windows MSVC fails with `os error 5` trying to re-link the running `pnpm-registry.exe`.
### Crates.io name reservations (from the original scaffold commit)
- [`pnpm-registry`](https://crates.io/crates/pnpm-registry) — published from this repo
- [`pnpm-registry-cli`](https://crates.io/crates/pnpm-registry-cli) / [`pnpm-registry-server`](https://crates.io/crates/pnpm-registry-server) — placeholder stubs, name reservation only
|
||
|
|
7a5cb92f80 |
chore(cargo): bump assert_cmd from 2.2.1 to 2.2.2 (#11853)
Bumps [assert_cmd](https://github.com/assert-rs/assert_cmd) from 2.2.1 to 2.2.2. - [Changelog](https://github.com/assert-rs/assert_cmd/blob/master/CHANGELOG.md) - [Commits](https://github.com/assert-rs/assert_cmd/compare/v2.2.1...v2.2.2) --- updated-dependencies: - dependency-name: assert_cmd dependency-version: 2.2.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> |
||
|
|
5353fcbf01 |
perf(pacquet): close the warm-cache resolve gap to pnpm CLI (#11837)
Closes #11832. On the `alotta-files` benchmark (1362 nodes, warm cache, GVS on), pacquet was 3-5× behind the TypeScript pnpm CLI whenever resolution ran (`firstInstall`, `withWarmCache`, `withWarmModules`, `updatedDependencies`). Wall-clock dropped from ~11.83s to ~5.03s on this branch; pnpm sits at ~4.16s, and the remaining gap is concentrated in the resolver's per-node `pick_package` walk (3.1s of the 5.03s — see #11843 for the `peekManifestFromStore` follow-up that would close it). The branch is a series of small wins rather than one big rewrite. The original `PrefetchingResolver` (commit |
||
|
|
f9a0abe02d |
test(pacquet/fs): port upstream multi-process CAS stress tests (#11823)
Adds the three cross-process scenarios upstream pnpm covers in
store/cafs/test/writeBufferToCafs.test.ts but pacquet only covered
intra-process (one 32-thread test): N workers racing on the same
target with a corrupt pre-seed, with a truncated pre-seed, and on a
clean target.
Pacquet's `cas_write_lock` is process-local (`OnceLock<DashMap<...>>`)
just like upstream's `locker: Map<string, number>`, so the cross-
process safety contract lives entirely in `O_CREAT | O_EXCL` +
atomic-rename. The existing 32-thread test in `ensure_file::tests`
exercises the lock; the new suite exercises the unprotected
filesystem-only path so a regression in the `verify_or_rewrite` +
`write_atomic` recovery would surface as a test failure instead of a
production install failing to import a CAS file at link time.
Approach:
- New `[[bin]]` `cafs_stress_worker` under `pacquet-fs/src/bin/`.
Reads a content fixture from argv[1] and a target path from
argv[2], calls `ensure_file`, exits 0/1. Tiny and test-only;
`pacquet-fs` is `publish = false` so an extra bin target is free.
- New integration test `pacquet-fs/tests/ensure_file_stress.rs`.
Uses `env!("CARGO_BIN_EXE_cafs_stress_worker")` to find the bin
Cargo builds alongside the test, then spawns 8 instances per
scenario via `std::process::Command`.
- Each scenario then asserts every worker exited 0 and the final
on-disk content sha-512-matches the expected payload.
Each recovery test was verified to catch a regression: temporarily
bypassing `verify_or_rewrite` flips both recovery tests red while
the clean-target test (which doesn't pre-seed anything) stays
green, matching upstream's coverage shape.
|
||
|
|
22d6742960 |
fix(pacquet): resolve catalog: in pnpm.overrides before freshness check (#11820)
The frozen-lockfile freshness check compared the lockfile's overrides map (with `catalog:` already expanded by pnpm) against the raw config map (still containing `catalog:` strings), so every catalog-backed override surfaced as `ERR_PNPM_OUTDATED_LOCKFILE` on every install. Mirror pnpm's `parseOverrides(overrides, catalogs)` → `createOverridesMapFromParsed` pipeline: thread `&Catalogs` through `parse_overrides[_iter]`, resolve each value via `resolve_from_catalog`, and flatten the resolved entries into the map handed to `check_lockfile_settings`. |
||
|
|
400b21a90f |
feat(pacquet): port pnpm-workspace.yaml overrides support to the install chain (#11793)
* feat(pacquet): port pnpm.overrides support to the install chain
Adds a new `pacquet-config-parse-overrides` crate (port of
`@pnpm/config.parse-overrides`), threads `overrides` through
`Config`/`WorkspaceSettings`, surfaces lockfile-side drift as
`StalenessReason::OverridesChanged` (matching upstream's
`getOutdatedLockfileSetting` overrides branch), and applies the parsed
overrides to a cloned root manifest before the frozen-lockfile
freshness check so post-override lockfile specifiers line up with the
on-disk manifest. The read-package-hook port (`VersionsOverrider`)
mirrors upstream's `createVersionsOverrider` minus the peer-arm
promotion, which is deferred until peer install lands. Catalog refs in
override values surface as `INVALID_OVERRIDES` until catalogs are
ported.
* chore(pacquet): satisfy Dylint Perfectionist lints and fix stale doc link
Renames single-letter closure / function / generic params introduced
by the overrides port to descriptive names, fixes trailing-comma
policy in test macro invocations, swaps the Windows path literal to
a raw string, and removes a stale `[`Self::root_dir`]` rustdoc link
left behind when the `root_dir` field was dropped from
`VersionsOverrider`.
* style(pacquet): apply rustfmt to install.rs overrides_map binding
* fix(pacquet/overrides): address review feedback
- `parse_overrides` doc no longer claims insertion-order behavior; it
accurately states that `HashMap` iteration is unordered and points
ordered-output callers at `parse_overrides_iter`.
- `WorkspaceSettings::apply_to` now collapses `overrides: {}` from a
later layer (env overlay, repeat `apply_to`) to `None` on `Config`,
so an explicit empty map clears an earlier non-empty assignment
instead of silently being skipped. Adds a regression test for the
env-overlay-clears-yaml shape.
- `sort_by_specificity` widens its comparator to a 3-way result so
Rust's `sort_by` total-order precondition holds. The strict
Less/Greater arms keep the sort outcome identical to upstream's
first-match choice; the `Equal` arm covers mutually-intersecting
ranges.
- `resolve_local_override_spec` routes the absolute-path and
diff-paths-fallback branches through `normalize_path` too, so
Windows `\` separators get rewritten to `/` for every `link:` /
`file:` shape (not just the diff-paths success branch).
|
||
|
|
667e587392 |
feat(pacquet): attach patch hashes to resolved pkg ids (#11791)
* feat(pacquet): attach patch hashes to resolved pkg ids Thread `patchedDependencies` into the tree walker so each matched package's `pkgIdWithPatchHash` gains the `(patch_hash=<hash>)` suffix upstream's `resolveDependencies.ts` produces. The peer resolver concatenates the peer suffix onto the patched id, so the install layer's depPath-keyed lookups land on the patched virtual-store slot without further changes. Surfaces `ResolvedTree::applied_patches` for the post-walk `ERR_PNPM_UNUSED_PATCH` check, and propagates `ERR_PNPM_PATCH_KEY_CONFLICT` from `get_patch_info` through the resolver error surface. * docs(pacquet): drop private-item intra-doc links The two `pub` items pointed at `resolve_node`, which is private — fine under `--document-private-items` but rejected when CI runs `cargo doc` with `-D rustdoc::private-intra-doc-links`. Rephrase to plain prose; the call site is obvious from the surrounding context. * style(pacquet/resolving-deps-resolver): drop trailing comma in assert! Dylint's `perfectionist::macro_trailing_comma` rejects a trailing comma on a single-line macro invocation. |
||
|
|
71dfccce9a |
feat(pacquet): port workspace: protocol resolution and publish-time rewrite (#11789)
* feat(pacquet): port workspace: protocol resolution and publish-time rewrite
Ports the `workspace:` family of bare specifiers end-to-end:
- `pacquet-workspace-spec` (new) ports `workspace/spec-parser`'s
`WorkspaceSpec` parser + `toString`.
- `pacquet-workspace-range-resolver` (new) ports
`workspace/range-resolver`'s `resolveWorkspaceRange` — `*`/`^`/`~`/`""`
pick the highest version with prereleases included; other inputs
follow standard semver range rules.
- `pacquet-resolving-npm-resolver` grows two helpers from the upstream
npm-resolver: `workspace_pref_to_npm` (port of `workspacePrefToNpm.ts`)
and `try_resolve_from_workspace` (port of `tryResolveFromWorkspace` +
`tryResolveFromWorkspacePackages` + `pickMatchingLocalVersionOrNull` +
`resolveFromLocalPackage`). `NpmResolver::resolve_impl` now intercepts
`workspace:` specs before the npm pick, deferring `workspace:./` /
`workspace:../` to the local resolver. Emits `link:` / `file:`
(injected) lockfile resolutions, with the matching
`WORKSPACE_PKG_NOT_FOUND` / `NO_MATCHING_VERSION_INSIDE_WORKSPACE`
/ `CANNOT_RESOLVE_WORKSPACE_PROTOCOL` error codes preserved.
- `pacquet-exportable-manifest` (new) ports the publish-time
`replaceWorkspaceProtocol` and `replaceWorkspaceProtocolPeerDependency`
helpers from `releasing/exportable-manifest`. The full
`createExportableManifest` (catalog rewrite, jsr rewrite, pre-pack
hooks, publishConfig overrides) lands as pacquet ports the surrounding
commands.
- `Install::run` builds a workspace-packages map via
`find_workspace_projects` when a `pnpm-workspace.yaml` is present and
threads it through `ResolveOptions::workspace_packages` so the
resolver chain can satisfy `workspace:` specs from local projects in
the no-lockfile install path.
Test ports:
- `workspace/spec-parser/test/workspace-spec.test.ts`
- `workspace/range-resolver/test/index.test.ts`
- `resolving/npm-resolver/test/workspacePrefToNpm.test.ts`
- `releasing/exportable-manifest/test/index.test.ts` (workspace cases)
- plus new unit tests for `try_resolve_from_workspace` covering
`WORKSPACE_PKG_NOT_FOUND`, `NO_MATCHING_VERSION_INSIDE_WORKSPACE`,
the inject branch, and the `publishConfig.directory` /
`linkDirectory` handling.
Frozen-lockfile installs already record `link:` entries directly; the
new resolution path matters for the no-lockfile install path.
---
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet): dylint + doc-link nits in workspace-protocol port
- Rename single-letter params (perfectionist::single-letter-*).
- Add trailing commas in multi-line macro invocations.
- Avoid the ambiguous `crate::parse_bare_specifier` doc link and the
cross-crate `pacquet_workspace_spec::WorkspaceSpec` /
`pacquet_workspace_range_resolver::resolve_workspace_range` doc
links (the crates don't have a Cargo dependency on each other, so
rustdoc can't resolve them).
---
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet): address coderabbit review comments
- replace_workspace_protocol_peer_dependency: use replacen("workspace:", "", 1)
so compound peer specs match upstream JS String.replace's first-only
semantics; locked with a new test
peer_workspace_strip_only_removes_first_occurrence.
- npm-resolver module docs: drop the stale "workspace: returns
Ok(None)" bullet and explain that non-path workspace specs now route
through try_resolve_from_workspace while path-relative forms still
fall through to the local resolver.
The third coderabbit nit (read_workspace_manifest error swallowing in
install.rs) was already addressed by the rebase: the workspace manifest
is now read once at the top of Install::run with proper error
propagation, and build_workspace_packages_map takes the pre-loaded
Option<&WorkspaceManifest> instead of re-reading the file.
---
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet/resolving-npm-resolver): surface WorkspacePkgNotFound.hint
Upstream pnpm's WORKSPACE_PKG_NOT_FOUND error carries a 'hint' field
that PnpmError prints as guidance after the message ('Packages found in
the workspace: ...'). The Rust port was populating the field but the
miette diagnostic didn't reference it, so the help text never reached
the user. Add help("{hint}") to the diagnostic attribute so miette
renders it under the message — matching pnpm's output verbatim.
---
Written by an agent (Claude Code, claude-opus-4-7).
|
||
|
|
b2a95fa1f7 |
feat(pacquet): port catalogs (types, protocol-parser, resolver, config) (#11787)
* feat(pacquet): port catalogs (types, protocol-parser, resolver, config)
Adds four new crates mirroring upstream's `catalogs/*` packages:
- pacquet-catalogs-types: `Catalog`/`Catalogs` map aliases plus
`DEFAULT_CATALOG_NAME`.
- pacquet-catalogs-protocol-parser: `parse_catalog_protocol`; folds
`catalog:` shorthand into `"default"` to match upstream.
- pacquet-catalogs-resolver: `resolve_from_catalog` with the four
upstream `PnpmError` codes (`CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`,
`CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION`,
`CATALOG_ENTRY_INVALID_WORKSPACE_SPEC`,
`CATALOG_ENTRY_INVALID_SPEC`). Rust callers `match` on the result
enum directly instead of porting the TS-ergonomic
`matchCatalogResolveResult` visitor.
- pacquet-catalogs-config: `get_catalogs_from_workspace_manifest` plus
the `INVALID_CATALOGS_CONFIGURATION` mutual-exclusion check.
`pacquet-workspace`'s `WorkspaceManifest` now actually deserializes
the `catalog` and `catalogs` fields (previously dropped). The
resolver is not yet wired into the install path — `deps-installer`
hasn't been ported — but the crates are ready for that next step.
Tests are 1:1 ports of the upstream Jest suites.
* fix(pacquet): satisfy rustdoc and perfectionist lints in catalogs port
- catalogs-resolver: drop the intra-doc-link form on the
`pacquet_resolving_resolver_base::WantedDependency` reference; the
crate isn't a dependency, so rustdoc failed to resolve it under
`-D rustdoc::broken-intra-doc-links`.
- catalogs-resolver, catalogs-config: reorder the `CatalogResolutionError`
/ `InvalidCatalogsConfigurationError` derive lists to
`Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq` so they
match the `prefix_then_alphabetical` rule that the CI-only
Perfectionist dylint enforces. `just ready` doesn't surface this lint
locally.
* feat(pacquet): resolve catalog: specifiers during install
Wires the catalogs port into the install path:
- `install.rs`: read `pnpm-workspace.yaml` after `find_workspace_dir`
and normalize via `get_catalogs_from_workspace_manifest` into a
`Catalogs` map. Adds `InstallError::{ReadWorkspaceManifest,
InvalidCatalogsConfiguration}` so the upstream
`ERR_PNPM_INVALID_CATALOGS_CONFIGURATION` propagates verbatim.
- `install_without_lockfile.rs`: threads the `Catalogs` map into
`ResolveDependencyTreeOptions`. (Frozen-lockfile catalog handling
needs a lockfile-snapshot pass and is a separate slice.)
- `resolve_dependency_tree`: replaces direct (importer-level)
`catalog:` bare specifiers with the catalog's recorded version
before the resolver chain dispatches. Catalog resolution does NOT
run on transitive deps, matching upstream's importer-only scope.
Misconfigured entries surface as `CatalogMisconfiguration` with
the upstream `ERR_PNPM_CATALOG_ENTRY_*` code instead of leaking
through to `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
Tests:
- deps-resolver: two new unit tests prove direct-dep rewriting and
the misconfiguration code path.
- cli (e2e): `install_resolves_catalog_protocol` runs the binary
against a workspace with a `catalog:` entry and checks the
virtual-store layout; `install_surfaces_catalog_misconfiguration`
asserts the upstream message is surfaced when the catalog has no
matching alias.
* style(pacquet): apply rustfmt to catalogs test after rebase
* fix(pacquet): rename single-letter closure param in catalogs e2e test
Perfectionist's `single_letter_closure_param` lint (CI-only via
Dylint) flagged `|c|` in the box-drawing-strip filter.
|
||
|
|
df990fdb51 |
feat(pacquet): port node/deno/bun runtime resolvers (#11783)
* feat(pacquet): port node/deno/bun runtime resolvers and wire them into the install chain
Ports the three `@pnpm/engine.runtime.*-resolver` packages and the shared
`@pnpm/crypto.shasums-file` helper into pacquet, and slots them into the
default-resolver chain so `node@runtime:<spec>`, `deno@runtime:<spec>`,
and `bun@runtime:<spec>` resolve through pacquet as they do in pnpm.
New crates under `pacquet/crates/`:
- `crypto-shasums-file` — downloads and decodes `SHASUMS256.txt`,
shared by node and bun. Mirrors `FAILED_DOWNLOAD_SHASUM_FILE`,
`NODE_INTEGRITY_HASH_NOT_FOUND`, `NODE_MALFORMED_INTEGRITY_HASH`.
- `engine-runtime-node-resolver` — `parse_node_specifier`,
`get_node_mirror`, `get_node_artifact_address`, `normalize_arch`,
`resolve_node_version[s]`, and the `Resolver`-impl entry point.
Handles the unofficial-musl mirror fan-out, the `lts` / LTS-codename
/ channel / range selectors, and the `darwin/arm64 <16 → x64`,
`win32/ia32 → x86`, `arm → armv7l` arch quirks. Error codes
`NO_OFFLINE_NODEJS_RESOLUTION`, `NODEJS_VERSION_NOT_FOUND`,
`INVALID_NODE_RELEASE_CHANNEL` match upstream.
- `engine-runtime-deno-resolver` — version selection delegates to the
npm resolver; assets come from the GitHub Releases API + per-asset
SHA256 sidecars. Windows x64 covers arm64 under emulation.
Errors: `DENO_RESOLUTION_FAILURE`, `DENO_MISSING_ASSETS`,
`DENO_GITHUB_FAILURE`, `DENO_PARSE_HASH`.
- `engine-runtime-bun-resolver` — version selection delegates to npm;
assets come from the GitHub-release `SHASUMS256.txt`. `windows` /
`aarch64` are normalised to `win32` / `arm64`. Error:
`BUN_RESOLUTION_FAILURE`.
Wiring (`install_without_lockfile.rs`): chain order is now
`npm → git → node → deno → bun`, matching upstream's
`resolving/default-resolver/src/index.ts` at
|
||
|
|
ee8fd0d4cf |
feat(pacquet): port auto-install-peers algorithm (#11784)
* feat(pacquet): port auto-install-peers algorithm
Replaces the placeholder peer-folding behavior with a faithful port of
pnpm's `hoistPeers` algorithm. Missing required peers are hoisted to
the importer's direct deps (shared across consumers, not nested), with
a multi-pass loop that re-resolves until no required peer remains and
the optional-peer pass picks already-available versions from the
preferred-versions map.
- New `pacquet-lockfile-preferred-versions` crate seeds the version-
picker tie-break table from manifest + lockfile snapshots.
- New `hoist_peers` module ports `hoistPeers` and
`getHoistableOptionalPeers` with the full upstream test suite.
- New `resolve_importer` orchestrator drives the multi-pass hoist
loop and threads `parent_pkg_aliases` / `all_preferred_versions`
across iterations.
- `resolve_dependency_tree` exposes `TreeCtx` / `extend_tree` /
`snapshot` so the orchestrator can extend the tree incrementally
without re-walking already-resolved subtrees.
- `auto-install-peers-from-highest-match` config setting added,
mirroring upstream's flag for range-merge behavior.
Ported from pnpm's installing/deps-resolver `resolveRootDependencies`
and hoistPeers.ts at commit
|
||
|
|
e2791ab6fe |
feat(pacquet): port named registries to the install chain (#11785)
* feat(pacquet): port named registries to the install chain Adds the user-facing `<alias>:` resolver surface so a manifest entry like `"@acme/private": "gh:^1.0.0"` resolves against GitHub Packages (or any other registry the user configures under `namedRegistries:` in `pnpm-workspace.yaml`). Mirrors upstream pnpm/pnpm@b61e268d57: - `parse_named_registry_specifier_to_registry_package_spec` parses `<alias>:[@<owner>/]<name>[@<version>]` and `<alias>:<version>` bodies. Rejects scope-without-name with `ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME`. - `merge_named_registries` folds the user map onto pacquet's built-in aliases (`gh:` -> GitHub Packages) and validates URLs at resolver construction (`ERR_PNPM_INVALID_NAMED_REGISTRY_URL`). - `NamedRegistryResolver` is a third `Resolver` alongside the npm / jsr paths, emitting `resolved_via: "named-registry"`. Auth headers flow through the existing per-URL `.npmrc` lookup. - `Config::named_registries` reads `namedRegistries:` from `pnpm-workspace.yaml`, with `${VAR}` substitution on the values (matches upstream's `replaceEnvInStringValues`). - `install_without_lockfile` constructs the merged map once and threads it through both the resolver chain (after npm/git, so configured aliases cannot hijack built-in schemes) and `build_resolution_verifiers` (so tarball-URL prefix routing honours user aliases). * fix(pacquet): rustdoc broken links + spell-check typo - `resolving-local-resolver/src/chain.rs`: drop the redundant explicit `(crate::...)` target on `[resolve_latest_from_local]` (clippy's `redundant_explicit_links` lint flagged it under `RUSTDOCFLAGS=-D warnings`) and demote the `[contains_path_sep]` intra-doc reference to backticks since the helper is private. - `resolving-npm-resolver/src/parse_bare_specifier.rs`: rename `unparseable` to `unparsable` in a doc comment so the workspace typos check passes. * test(pacquet): cover the remaining named-registry test cases Ports upstream's [`resolving/default-resolver/test/namedRegistry.ts`](https://github.com/pnpm/pnpm/blob/b61e268d57/resolving/default-resolver/test/namedRegistry.ts) and the two `resolveNamedRegistry.test.ts` cases the previous commit hadn't covered. - New `tests/chain.rs` integration tests assert that the explicit `link:` / `workspace:` / `file:` schemes win over a colliding named-registry alias. These pin the local-scheme / local-path split: with combined `LocalResolver`, named-registry would slot *after* both halves and a `link` alias could hijack `link:./pkg`. - `resolves_via_builtin_gh_alias` covers the `gh:` happy path that was previously only exercised via a user-supplied `work:` alias. - `preserves_scoped_pkg_name_when_alias_differs` verifies that the resolver records the dependency under the registry's name, not the local manifest alias. - `user_config_overrides_builtin_gh_alias` covers the GHES override scenario where a user points `gh` at their enterprise host. 11 resolver tests pass (5 unit + 3 chain integration + 3 happy-path scenarios) plus the 12 parser cases already in place. |
||
|
|
a8a8cbce6d |
feat(pacquet): port resolving.local-resolver (file:/link:/workspace:) (#11778)
* feat(pacquet/resolving-local-resolver): port file:/link:/workspace: resolver
Ports pnpm's @pnpm/resolving.local-resolver:
- parse_bare_specifier mirrors parseLocalScheme/parseLocalPath/fromLocal
(link:/workspace:/file: prefixes, bare path shapes, tarball-filename
detection, tilde/drive-letter handling, preserveAbsolutePaths,
injected directories get file: not link:).
- local_resolver provides resolve_from_local_scheme / _path /
resolve_latest_from_local matching upstream's three exports;
ssri-based tarball integrity for file: tarballs;
safe_read_package_json_from_dir for directories with the upstream
fallback (warn + name=basename / version='0.0.0' for missing link:
targets, LINKED_PKG_DIR_NOT_FOUND for missing file: targets,
NOT_PACKAGE_DIRECTORY for ENOTDIR + Windows stat-check) and
PATH_IS_UNSUPPORTED_PROTOCOL for path:.
- chain.rs wraps the free functions behind the Resolver trait so the
default-resolver dispatcher can compose this in alongside the npm
resolver. ResolveResult.name_ver is None for local resolutions —
the canonical name lives in the fetched manifest, not the
resolver-time signal.
17 ported tests mirror resolving/local-resolver/test/index.ts plus
3 chain-dispatch tests verifying the trait wiring. The missing-link:
target warn is emitted via tracing::warn! because pacquet's reporter
doesn't yet have a generic pnpm:logger channel.
Install-side wiring is left for a follow-up alongside the Stage-1
directory-fetcher integration: surfacing Directory resolutions to
install_without_lockfile today would only swap the
SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER error for an UnsupportedResolution
one in install_package_from_registry.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet): satisfy doc + dylint CI
Doc:
- pacquet_directory_fetcher intra-doc link was unresolved
(resolving-local-resolver has no such dep — it's a sibling).
- LocalSpecError doc linked to crate-private parse_local_scheme /
parse_local_path.
Dylint (perfectionist):
- PkgResolutionId / WantedLocalDependency / ParseOptions /
PathProtocolNotSupportedError derive lists reordered to
prefix_then_alphabetical.
- Single-letter closure params (|p|, |s|, |v|) renamed.
- Impure expression passed to tracing::warn! bound to a let first.
- Multi-line format!/assert_eq! macro invocations gained trailing
commas; the single-line assert! shed its stray trailing comma.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet/resolving-local-resolver): surface NOT_PACKAGE_DIRECTORY on Windows
`safe_read_package_json_from_dir` opens `<spec>/package.json` and lets
the OS error surface. On Unix that's `ENOTDIR` for a file path; on
Windows it's `NotFound`, so the resolver fell through to the
fallback-manifest branch instead of returning `NOT_PACKAGE_DIRECTORY`.
Upstream pnpm has the same gap on Windows and patches around it inside
`readProjectManifestOnly` (workspace/project-manifest-reader/src/index.ts#L100-L114
at
|
||
|
|
606ff8f648 |
feat(pacquet): port resolving/tarball-resolver (#11773)
* feat(pacquet): port resolving/tarball-resolver Adds pacquet-resolving-tarball-resolver, the Rust port of resolving/tarball-resolver/src/index.ts. The new crate claims any WantedDependency whose bare specifier starts with `http://` or `https://`, normalizes the URL through `reqwest::Url::parse`, runs a HEAD pre-flight that follows redirects, and stores the post-redirect URL in the resolution when the response carries `cache-control: immutable`. Mutable responses keep the normalized request URL. Integrity stays None at resolve time, matching upstream (integrity is stamped later in package-requester). To make the seam fit, ResolveResult.id is now an opaque `PkgResolutionId(String)` newtype in resolver-base mirroring upstream's branded string at core/types/misc.ts:59. PkgNameVer was a poor fit because tarball ids are URLs and git ids are `repo#commit` — not name@version. NpmResolver round-trips the existing PkgNameVer through PkgResolutionId::from(string); the npm-only install paths in package-manager parse the id back to PkgNameVer at their boundary (safe because the npm resolver stamps that shape). The deps-resolver alias fallback drops its `.id.name` access since the id is opaque now. Test coverage in the new crate (7 tests, mockito): http(s) claim vs decline, mutable vs immutable response, immutable-after-redirect follow, resolve_latest for http(s) vs non-http(s). * fix(pacquet/resolving-tarball-resolver): rename single-letter closure params Dylint's perfectionist::single-letter-closure-param rejects |v|; rename to |header| in the cache-control header check. * feat(pacquet/package-manager): wire TarballResolver into the install chain Insert TarballResolver after the GitResolver in install_without_lockfile's DefaultResolver chain so http(s):// bare specifiers actually route through the new resolver. Order mirrors upstream's chain (npm → git → tarball → local/...). * fix(pacquet/resolving-tarball-resolver): forward wanted alias and resync lockfile - Echo `wanted_dependency.alias` on the `ResolveResult` so a spec like `"foo": "https://.../bar.tgz"` preserves `foo` as the install name downstream. Matches the npm and git resolvers' convention even though upstream TS doesn't surface alias on `ResolveResult` (downstream consumers fall back to `wantedDependency.alias` over there). - Drop a stray blank line in resolve.rs that cargo fmt rejected. - Record the new `package-manager -> resolving-tarball-resolver` edge in Cargo.lock so `cargo build --locked` succeeds on CI. |
||
|
|
35d5440ce1 |
feat(pacquet): port resolving/git-resolver and wire it into the install chain (#11779)
* feat(pacquet): port resolving/git-resolver and wire it into the install chain Adds `pacquet-resolving-git-resolver`, the Rust port of pnpm's `@pnpm/resolving.git-resolver`. Recognises GitHub / GitLab / Bitbucket shortcut forms and full `git+ssh:` / `git+https:` / `ssh:` / plain `https://…/repo.git` URLs, runs `git ls-remote` to pin the commit (partial commit search, annotated-tag dereference, semver-range matching), and emits either a git-hosted tarball resolution or a `Git{repo,commit}` resolution. Production runners shell out to the system `git` binary via `tokio::task::spawn_blocking` and use the install-wide `ThrottledClient` for the HEAD probe. Widens the resolver-base contract so URL-shaped IDs fit: adds a `PkgResolutionId` newtype (rule-3 branded string, infallible `From<String>`/`From<&str>`/`From<&PkgNameVer>`), changes `ResolveResult.id` to that type, and adds `name_ver: Option<PkgNameVer>` so callers that need the structured `name@version` form keep working. npm-resolver fills both fields; git-resolver leaves `name_ver` `None` (the install path that consumes git resolutions hasn't landed yet, so those call sites panic with a TODO message until then). `DefaultResolver` now implements `Resolver` too (returns `Ok(None)` when no resolver in the chain claims), letting `resolve_dependency_tree` accept the chain directly. The install-side wiring in `install_without_lockfile.rs` constructs `DefaultResolver::new(vec![Box::new(npm_resolver), Box::new(git_resolver)])` with `RealGitProbe` / `RealGitRunner`, mirroring upstream's `createResolver` chain order. Test coverage: 51 unit tests in the new crate, including the full SCP-style URL repair matrix ported from `parsePref.test.ts` and the GitLab `/-/archive/` tarball regression for pnpm #11533. Full workspace `cargo nextest run` is green at 1635 tests. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(pacquet): satisfy dylint perfectionist + rustdoc on git-resolver port * Reorder `#[derive(...)]` on `PkgResolutionId` to match the `prefix_then_alphabetical` rule the dylint Perfectionist lint enforces (`From` last after `Serialize`/`Deserialize`). * Add `()` to function intra-doc links that collide with same-named modules (`create_git_hosted_pkg_id`, `parse_bare_specifier`) so rustdoc's `broken-intra-doc-links` lint stops treating them as ambiguous. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(pacquet): satisfy Perfectionist dylint lints on git-resolver port CI's `just ready` doesn't surface Perfectionist (it runs only as a dedicated dylint job on a nightly toolchain). Fixes: * Rename single-letter generics `P`/`R` → `Probe`/`Runner` on `GitResolver`, `PartialSpec::finalize`, `from_hosted_git`, and `resolve_ref`. * Rename single-letter closure / function / let-binding params (`s`/`h`/`c`/`p`/`i`/`g`/...) to descriptive names. * Replace Unicode ellipsis (`…`, U+2026) with ASCII `...` in comments. * Add trailing commas to multi-line `assert_eq!` / `assert!` invocations, and remove the stray trailing comma on a single-line one. Also fix follow-on JSR-resolver test cases that still read `result.id.{name,suffix}`: switch them to `result.name_ver.as_ref()...` to match the post-widening `ResolveResult` shape. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(pacquet): address PR review on git-resolver port * Replace the two `.expect()` calls on `ResolveResult.name_ver` in the install path with `.ok_or_else()` that surfaces a typed error: `InstallPackageFromRegistryError::UnsupportedResolution` and a new `InstallWithoutLockfileError::UnsupportedInstallResolution`. Now that the git resolver is in the chain, a git/tarball/local resolution reaching the without-lockfile install path returns an error end-to-end instead of panicking. Add a regression test pinning the contract. * Make `percent_decode` (in `hosted_git.rs`) and `percent_decode_str` (in `parse_bare_specifier.rs`) UTF-8 aware: collect decoded bytes into a `Vec<u8>` and reassemble via `String::from_utf8`, falling back to the original input on malformed UTF-8 (matches Node's `decodeURIComponent` throwing a `URIError` that upstream's `try/catch` swallows). The byte→`char` cast was corrupting any multi-byte sequence (e.g., `%E2%80%A6` → ellipsis); regression test added. --- Written by an agent (Claude Code, claude-opus-4-7). * chore(pacquet): drop unused UnsupportedInstallResolution after rebase Main's `feat(pacquet): peer-dependency resolution stage` reworked `install_without_lockfile.rs` to derive the virtual-store name from the resolved depPath via `pacquet_deps_path::dep_path_to_filename` instead of reading `result.name_ver`. That removed the `.expect()` / `.ok_or_else()` site this error variant was added for; with no remaining callers, drop the dead variant. --- Written by an agent (Claude Code, claude-opus-4-7). |
||
|
|
a1f91e1770 |
feat(pacquet): peer-dependency resolution stage (#11774)
* feat(pacquet): peer-dependency resolution stage Ports pnpm's `resolvePeers` algorithm to pacquet's `resolving-deps-resolver` crate and wires it into `install_without_lockfile`. The depPath-keyed `DependenciesGraph` produced by the new stage replaces the flat `(name, version)` keying the install pass used to drive virtual-store slot names from. New `pacquet-deps-path` crate ports the `@pnpm/deps.path` helpers (`createPeerDepGraphHash`, `depPathToFilename`, balanced-paren suffix scan). `ResolvedTree` now carries both the flat dedup map (`packages`) and the per-occurrence tree (`dependencies_tree`, keyed by a new `NodeId`) so two parents sharing the same package can compute different peer suffixes — the whole point of the stage. Cycles in the tree pass are broken by skipping the cycled edge entirely (matches upstream's `parentIdsContainSequence` gate in `buildTree`), and peer-resolution falls back to `name@version` peer-ids when re-entering an in-progress node. Three upstream optimisations are intentionally not ported in this slice: `peersCache`, the `purePkgs` fast path, and `graph-cycles`-driven async deferment. Each is correctness-preserving — the algorithm produces the same depPaths, just without the short-circuits. `autoInstallPeers` keeps its existing "fold peers into the regular walk" behavior until the full `hoistPeers` algorithm lands. * fix(pacquet/deps-path): satisfy Doc / Dylint / Spell Check CI checks - Dylint perfectionist::single-letter-let-binding: rename `i` → `cursor` in `suffix_index.rs`. - Dylint perfectionist::macro-trailing-comma: drop trailing comma in the `dep_path_to_filename` `file:` test. - rustdoc broken-intra-doc-links: link `[`DependenciesTree`]` to the newly-exported `pacquet_resolving_deps_resolver::DependenciesTree` type alias and downgrade the `pacquet_lockfile::PkgNameVerPeer` mention in `pacquet-deps-path`'s module doc to plain text (the crate deliberately doesn't depend on `pacquet-lockfile`). - rustdoc fn-vs-mod ambiguity: prefix [`resolve_dependency_tree`], [`resolve_peers`], and [`create_peer_dep_graph_hash`] doc-links with `fn@` so they bind to the function rather than the same-named module. - typos: rename `unparseable` → `unparsable` in a test name. * fix(pacquet/resolving-deps-resolver): satisfy Dylint perfectionist lints - `derive_ordering`: reorder `#[derive(...)]` on `DepPath` and `NodeId` to match the configured `prefix_then_alphabetical` style — `PartialOrd, Ord, Hash` rather than `Hash, PartialOrd, Ord`. See `dylint.toml`'s `[perfectionist::derive_ordering]` prefix list for the canonical order. - `single_letter_function_param`: rename `s` → `value` on `impl From<String> for DepPath`. * fix(pacquet): address PR review feedback Round of correctness fixes flagged by CodeRabbit and qodo-code-review on #11774. - **`dep_path_to_filename_unescaped`**: guard against empty / single-byte input. The previous `trimmed.as_bytes()[1..]` slice panicked when `trimmed.len() < 2`; the function now short-circuits to `trimmed.to_string()` in that case, mirroring upstream's `indexOf` with `fromIndex = 1` returning -1 on out-of-range scan. - **`extract_children` / `extract_peer_dependencies` mismatch**: the child walker added every `peerDependencies` entry when `auto_install_peers` was on, but the peer-resolution stage's `peerDependenciesWithoutOwn` filter skips peer names also in `dependencies` / `optionalDependencies`. `BTreeMap` collection of the result by alias could silently drop the optional edge for a name that appeared in both. Fix: collect `optionalDependencies` into children too, and dedupe peer entries against own deps before appending. - **Dropped peer edges in `graph_children`**: when a peer pointed at a later sibling direct dep (e.g., manifest order `{ react-dom: …, react: … }`), the peer's `DepPath` wasn't in `node_dep_paths` yet at the time the parent's `graph_children` was built, so the symlink edge was silently dropped. Install would then walk react-dom's slot without finding react. Add `pending_peer_edges` + a `patch_pending_peer_edges` post-pass that runs after every direct dep is walked, with regression test. - **Aliased child peer misfilter**: the "is this peer one of my own children?" check compared the peer alias against `tree_node.children` keys, but `children` is keyed by install alias while peers can match by real name via the dual-keyed `ParentRefs`. Switch both filter sites to compare by `NodeId` so an npm-aliased child satisfying a peer is correctly classified as internal. - **`DepPath` newtype consolidation**: `PeerId::DepPath(String)` collapsed the depPath brand. Move the `DepPath` newtype from `resolving-deps-resolver::dependencies_graph` into the lower-level `pacquet-deps-path` crate so the `PeerId` enum carries the branded type instead of `String`. `resolving-deps-resolver` re-exports `DepPath` from `pacquet_deps_path` to keep existing imports working. - **Prerelease-tolerant semver match**: `node-semver`'s `Range::satisfies` rejects prereleases against non-prerelease comparators (`18.0.0-rc.1` vs `^18.0.0` returns `false`). Add a fallback in `satisfies_with_prereleases`: if the straight check fails and the candidate is a prerelease, retry against the stripped `MAJOR.MINOR.PATCH` base. Approximates Yarn's `satisfiesWithPrereleases` for the cases pnpm cares about without importing the full per-comparator algorithm. The `tracing::warn!` peer-issue emission flagged by qodo and the deeper prerelease semantics gap (per-comparator) remain documented follow-ups; the slice's `resolve_peers.rs` module doc lists what's intentionally not ported in this PR. * fix(pacquet): apply cargo fmt to peer-resolution slice Three sites where the previous edits left non-canonical wrapping: - `create_peer_dep_graph_hash.rs` test calls now fit one line. - `lib.rs` re-export ordering (alphabetical). - `tests.rs` whitespace. CI Format check is `cargo fmt --all -- --check`; the local pre-push hook didn't catch these because the edits landed via `Edit` without a follow-up `cargo fmt`. * fix(pacquet/resolving-deps-resolver): drop trailing comma rustfmt re-added `cargo fmt` collapsed the regression test's `assert_eq!` onto one line and kept the trailing comma, which tripped the same `perfectionist::macro_trailing_comma` rule that #11774's earlier commit fixed elsewhere. The lint forbids a trailing comma on single-line macro calls; rustfmt leaves it alone. Drop the comma to satisfy both. |
||
|
|
f807f6d402 |
feat(pacquet): port JSR specifier parser and wire resolve_jsr (#11772)
* feat(pacquet/resolving-npm-resolver): port JSR specifier parser and wire resolve_jsr Adds a new `pacquet-resolving-jsr-specifier-parser` crate that ports `@pnpm/resolving.jsr-specifier-parser` (`parseJsrSpecifier` + `JsrSpec`), mirroring upstream's `ERR_PNPM_MISSING_JSR_PACKAGE_SCOPE` / `ERR_PNPM_INVALID_JSR_PACKAGE_NAME` / `ERR_PNPM_INVALID_JSR_SPECIFIER` error codes 1:1. Wires the parser into the npm resolver: - `parse_jsr_specifier_to_registry_package_spec` adapter alongside `parse_bare_specifier`, matching upstream's same-file shape in `resolving/npm-resolver/src/parseBareSpecifier.ts`. - `NpmResolver::resolve_impl` detects `jsr:` early and routes to a new `resolve_jsr_impl` that picks against `registries['@jsr']` (with the `https://npm.jsr.io/` `DEFAULT_REGISTRIES` fallback) and stamps `resolved_via = "jsr-registry"` plus `alias = spec.jsr_pkg_name`. - Extracts a shared `pick_from_registry` helper so npm and JSR paths share the picker invocation; `build_resolve_result` now takes a `resolved_via` parameter. Ports the 6 upstream parser test cases and adds adapter + resolver integration tests covering the JSR happy path, default-tag fallback, and parser-error propagation. Ports https://github.com/pnpm/pnpm/blob/05dd45ea82/resolving/jsr-specifier-parser/src/index.ts and https://github.com/pnpm/pnpm/blob/1627943d2a/resolving/npm-resolver/src/index.ts. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(pacquet/resolving-jsr-specifier-parser): treat empty alias as missing in version-only specs Upstream's `parseJsrSpecifier` guards the version-only branch with the truthy check `if (!alias)`, so an empty string falls into the `INVALID_JSR_SPECIFIER` arm. Pacquet's `let Some(alias) = alias else` form only triggers on `None`, so `Some("")` was instead carried into `jsr_to_npm_package_name` and surfaced `MISSING_JSR_PACKAGE_SCOPE`. Filter empties out before the destructure so both sides agree on the error code. Also tighten the resolver-level integration test for the unscoped-name case to assert the upstream-defined error message instead of a generic "JSR" substring. Reported by CodeRabbit on https://github.com/pnpm/pnpm/pull/11772. --- Written by an agent (Claude Code, claude-opus-4-7). |
||
|
|
c068720dec |
fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG (#11768)
* fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG Peer-heavy snapshot keys (e.g. vitest with a dozen browser / coverage / DOM peers) produced flat-name directories that overflowed macOS's 255- byte filename limit, so `install` aborted with errno 63 before unpacking any tarballs. Port the trailing length / case-shortening branch of upstream's `depPathToFilename` (deps/path/src/index.ts:169) so the name becomes `<prefix>_<32-hex-sha256>` capped at `virtualStoreDirMaxLength` bytes (default 120). Extract `create_short_hash` and `shorten_virtual_store_name` into a new `pacquet-crypto-hash` crate mirroring upstream `@pnpm/crypto.hash`; `pacquet-lockfile`, `pacquet-registry`, and `pacquet-store-dir` all consume it instead of duplicating the sha2 + truncate logic. Reported via pnpm/pacquet issue triage (vitest@4.1.6 peer suffix). * fix(pacquet): taplo format and remove broken intra-doc link Format `pacquet/crates/crypto-hash/Cargo.toml` per the workspace `.taplo.toml` (aligns the `[package]` keys) and downgrade the `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH` reference in `PkgNameVerPeer::to_virtual_store_name` to plain text, since `pacquet-lockfile` deliberately does not depend on `pacquet-modules-yaml` and `RUSTDOCFLAGS=-D warnings` rejected the unresolved intra-doc link. * feat(pacquet/config): expose virtualStoreDirMaxLength Add `virtual_store_dir_max_length: u64` to `Config` with default 120 (matching `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`). Wire it through `WorkspaceSettings.virtualStoreDirMaxLength` and the `PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH` env-overlay so users can override the threshold via `pnpm-workspace.yaml`, global `config.yaml`, or environment variables — mirroring upstream `Config.virtualStoreDirMaxLength`. The three flat-name call sites (`install_without_lockfile.rs`, `install_package_from_registry.rs`, `virtual_store_layout.rs`) and the `.modules.yaml` writer now read the configured value instead of the hardcoded constant. `VirtualStoreLayout::legacy` takes the value as an explicit second arg so test fixtures don't silently inherit a default. |
||
|
|
097983fbca |
feat(pacquet): wire NpmResolver into install; fix(pick-registry) unscoped npm-alias routing (#11760)
Two changes ship together: the bulk is the pacquet refactor described in #11756, plus a TypeScript-side fix to `@pnpm/config.pick-registry-for-package` that surfaced during review. ### Pacquet — wire NpmResolver into install (Phases A/B/C of #11756) - **Phase A.** New `parse_bare_specifier.rs` and `npm_resolver.rs` in `pacquet-resolving-npm-resolver`. `NpmResolver` implements the `Resolver` trait: parses the bare specifier (including npm-alias `npm:@scope/name@<spec>` and tarball-URL forms — with prefix-anchored name validation), picks a version via `pick_package`, surfaces `minimumReleaseAge` violations inline via `detect_min_release_age_violation`. `workspace:` specs decline so the chain falls through. `published_by` / `published_by_exclude` / `dry_run` added to `ResolveOptions`. - **Phase B.** `install_without_lockfile.rs` constructs an `NpmResolver` at install entry from the config-derived registries map and an `InMemoryPackageMetaCache` that's shared across the resolve pass and dropped before the install pass. - **Phase C.** New `pacquet-resolving-deps-resolver` crate exposes `resolve_dependency_tree`: a flat `name@version`-keyed package map with parent-child edges, concurrent sibling resolution via `try_join_all`, per-id dedup gate. `install_package_from_registry.rs` no longer calls `Package::fetch_from_registry` / `Package::pinned_version`; it takes a pre-resolved `ResolveResult` and reads tarball URL + integrity off `LockfileResolution::Tarball`. Additional behaviors landed during review: - **`minimumReleaseAge` policy in the resolve pass.** Previously only enforced by the lockfile-verification gate; the no-lockfile resolve pass now derives `published_by` and the exclude policy from `Config` so resolver-time picks match the configured policy. - **`SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER` surfaces correctly.** `resolve_dependency_tree` now returns a typed error when the chain returns `Ok(None)` — silently dropping the edge would leave installs missing transitive deps. Mirrors upstream's `default-resolver` error shape. - **Per-package progress events.** `InstallPackageFromRegistry` takes a `first_visit: bool`; `pnpm:progress resolved` / `pnpm:progress imported` plus the tarball download fire once per `(name, version)`, while the per-parent `symlink_package` runs on every edge. Matches upstream's per-package (not per-edge) reporter contract. - **Windows symlink race fix.** `ResolvedPackages` is now `DashMap<String, watch::Sender<bool>>`; the first writer signals completion after `import_indexed_dir`, so a second visitor's `symlink_package` (which may fall back to a Windows junction requiring an existing target) doesn't race ahead of the materialization. A dropped first-writer task surfaces as a typed `FirstWriterAborted` error. - **Scope routing.** `pick_registry_for_package` is now bareSpecifier-aware so an entry like `"foo": "npm:@acme/bar@^1"` routes through `registries[@acme]`. ### TS — `@pnpm/config.pick-registry-for-package` unscoped-target fix A separate bug surfaced during the scope-routing port: `pickRegistryForPackage('@private/foo', 'npm:lodash@^1')` was routing through `registries['@private']`, even though `lodash` is unscoped and doesn't live on the `@private` registry. `getScope` now returns `null` in the npm-alias branch when the alias target is unscoped (instead of falling through to the local pkgName's scope). Changeset is in `.changeset/pick-registry-unscoped-npm-alias.md` (patch bump for `@pnpm/config.pick-registry-for-package` and `pnpm`). Added matching tests on both the TS and pacquet sides. ### Out of scope (left as #11756 follow-ups) - Preferred-versions harvesting from the lockfile (Phase D). - Install-side aggregation of `policy_violation` from the tree (Phase E) — the resolver attaches them per-pick already, but the install layer doesn't yet collect or fail on them. - Other-protocol resolvers (git, tarball, workspace, jsr, named-registry, …) — `NpmResolver` is the only chain entry today; once a second resolver lands, `DefaultResolver` will get wired in too. - Full `parseBareSpecifier.test.ts` corpus port — the parser tests pacquet ships cover the cases the install path exercises; remaining corpus items land alongside Phase F. Closes part of #11756. |
||
|
|
df77f649ee |
fix(pacquet/fs): serialize concurrent CAS writes to the same path (#11758)
* fix(pacquet/fs): serialize concurrent CAS writes to the same path
Two install snapshots whose tarballs ship identical file content
(e.g. a shared LICENSE across sibling packages like
`@pnpm.e2e/hello-world-js-bin` and `@pnpm.e2e/hello-world-js-bin-parent`)
hash to the same CAS path and call `ensure_file` concurrently. Without
serialization the second writer's `O_CREAT|O_EXCL` hits `AlreadyExists`
while the first writer is mid-`write_all`, falls into
`verify_or_rewrite`'s `meta.len() != content.len()` arm, and runs
`write_atomic` — which renames a temp file over the live source.
On Linux/ext4 the partial-size observation is fast enough that this
window opens often, surfacing as flaky CI failures of the form:
failed to import "<store>/v11/files/65/<hash>" to ".../LICENSE":
No such file or directory (os error 2)
emitted by `link_file` whose `reflink`/`fs::hard_link` raced the
rename. macOS/APFS and Windows tests pass because their `stat` cadence
and CI runner parallelism don't reliably open the window.
Port pnpm v11's `locker: Map<string, number>` semantics — slightly
stronger in pacquet, as a per-path `Mutex` rather than a dedup cache —
so two writers of the same CAS path serialize through it. The second
caller acquires the lock after the first writer's `write_all` has
finished, then takes the byte-match fast path inside `verify_or_rewrite`
and never has to rewrite. The previous docstring at the bottom of the
"Differences from pnpm" list explicitly acknowledged the locker
omission and predicted it would matter; this change closes that gap.
Add a regression test (`concurrent_writers_of_same_path_do_not_swap_the_inode`)
that fires 32 threads at one path with identical content and asserts
the inode never swaps — the observable signal that no writer ever took
the `write_atomic` rename path.
* fix(pacquet/fs): unlink intra-doc references to private items
`ensure_file` is public; references to private `verify_or_rewrite`,
`write_atomic`, and `cas_write_lock` only resolve under
`--document-private-items`, which trips `rustdoc::private-intra-doc-links`
under `-D warnings`. Drop the link form and keep the names as plain
backticked identifiers — the docstring still reads correctly and
`cargo doc` no longer fails.
* style(pacquet/fs): rename single-letter N to WRITER_COUNT in test
Address review feedback on #11758 — single-letter constants are
opaque; `WRITER_COUNT` reads as the loop count of concurrent
writers the test fires at one CAS path.
* docs(pacquet/fs): tighten ensure_file / cas_write_lock / test docs
Address review feedback on #11758: drop body-narrating prose, keep
only the contract and the non-obvious why. The test docstring no
longer references "pre-fix code" so it reads independent of this
PR's history.
* test(pacquet/fs): pre-create + per-thread inode capture in concurrent test
Address CodeRabbit's and Copilot's review feedback on #11758. The
previous assert compared two metadata reads taken at the same moment
after join — tautological. Pre-creating the file gives an
`original_ino` reference taken before the contended run, and each
writer also captures the path's inode immediately after its own
`ensure_file` returns so a mid-run rename swap from another writer
is visible to at least one of those observations.
* style(pacquet/fs): add trailing comma in multi-line assert! macro
Address `perfectionist::macro_trailing_comma` warning surfaced by
the Dylint CI on #11758 — multi-line macro invocations must end with
a trailing comma. (My earlier local `dylint --all` run missed this
because the perfectionist library hadn't fully recompiled against the
new test code.)
* test(pacquet/fs): drop pre-create from concurrent writer test
Address Copilot review feedback on #11758. The pre-create made every
contender take the byte-match fast path against a complete file, so
the lock made no observable difference and the test couldn't even
weakly distinguish lock from no-lock. Removing it leaves the
fresh-dirent shape (`O_CREAT|O_EXCL` race + verify_or_rewrite for
the rest), and the per-thread inode observations can now diverge
under a multi-rename race without the lock.
Honest about the limitation in the docstring: any observation taken
after `ensure_file` returns has already missed the rename window, so
a single-rename race converges on one inode and slips past. The
test catches the multi-rename case and validates the "no deadlock,
all writers see correct content" baseline.
---------
Co-authored-by: Claude <noreply@anthropic.com>
|