fix(installing.commands): forward pnpm install flags to pacquet (#11781)

* fix(installing.commands): forward `pnpm install` flags to pacquet

When the install engine is delegated to pacquet via configDependencies,
pnpm hard-coded the args to `install --frozen-lockfile --reporter=ndjson`
and silently dropped the user's other CLI flags. `pnpm install --no-runtime`
therefore still installed the workspace's runtime devDependency, clobbering
the Node version the surrounding tooling had set up — visible as the
`Verify Node version` failure on PR #11765 where setup-pnpm provisions
Node 24.0.0 but pacquet then materializes node 24.6.0.

Pacquet's `install` subcommand already mirrors pnpm's surface for the
common flags (`--no-runtime`, `--prod`, `--dev`, `--no-optional`,
`--node-linker`, `--offline`, `--prefer-offline`, `--cpu`/`--os`/`--libc`).
Forward the user's argv verbatim when the command is `install`/`i`;
`add`/`update`/`dedupe` still don't forward — their flag surfaces don't
line up with pacquet's `install`.

* fix(installing.commands): pass --ignore-manifest-check to pacquet

`pnpm up` / `add` / `remove` were aborting with
`pacquet_package_manager::outdated_lockfile` whenever pacquet was
declared in `configDependencies`. After resolving and writing the
updated lockfile, pnpm hands materialization off to pacquet but
hasn't yet written the post-mutation `package.json` — that write
happens after `mutateModules` returns. Pacquet's frozen-lockfile
freshness gate then saw the new lockfile paired with the
pre-mutation manifest and refused to install.

Pass pacquet's new `--ignore-manifest-check` flag (pacquet PR #11811)
on every delegation. The flag is narrow: it only skips
`satisfies_package_manifest`. Settings drift like `overrides` is
still enforced, and pnpm already re-validated the lockfile before
delegating, so re-checking the manifest here was redundant work that
only ever fired false positives on the mutate-then-materialize path.

Requires a pacquet release that ships the flag; bump
`PACQUET_VERSION` in `pnpm/test/install/pacquet.ts` once it does, or
the existing e2e tests will fail against pacquet 0.2.2-9 (which
doesn't recognize the flag and clap would reject).

Closes #11797.

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

* fix: update pacquet in tests

* fix(installing.commands): strip positionals + always-injected flags when forwarding to pacquet

`collectForwardedFlags` checked `argv[0] === 'install'` to find the
command token to strip. Any global flag the user typed before `install`
(e.g. `--config.registry=...` in the e2e test) shifted the token out
of position, so the function returned the full argv and pacquet saw
`install` twice — `error: unexpected argument 'install' found`.

Use the parsed argv that `@pnpm/cli.parse-cli-args` already produced:
`remain` lists positionals (the `install`/`i` token and nothing else
on this code path, since `isInstallCommand` is only true when no
package params are present), and `original` preserves the user's
exact tokens. Drop positionals + the flags we always inject
(`--reporter=ndjson`, `--frozen-lockfile`, `--ignore-manifest-check`)
so clap doesn't reject duplicates either.

`original` over `cooked` deliberately: nopt's `cooked` splits
`--key=value` into two tokens, which would break pacquet's
`--config.<key>=<value>` parser (it requires the `=` form).

* fix(installing.commands): make argv.cooked/remain optional on InstallCommandOptions

Widening these to required broke test fixtures elsewhere (publish/pack/
deprecate/dist-tag/deploy) that construct minimal `argv: { original }`
options for code paths that never reach pacquet. Only the pacquet
delegation actually reads `remain`, so make the two new fields optional
on the shared options type and supply a default at the runPacquet call
site. The runtime path through main.ts already populates all three.

* fix(installing.commands): strip any user-supplied --reporter when forwarding to pacquet

Pacquet's `--reporter` is a clap value option with last-value-wins
semantics, so `pnpm install --reporter=silent` (or
`--reporter silent` two-token form) reached pacquet and overrode
the `--reporter=ndjson` pnpm injects, breaking the NDJSON-to-
streamParser plumbing the default reporter depends on. The previous
filter only matched the exact `--reporter=ndjson` token.

Walk argv with a lookahead so both `--reporter=<value>` and
`--reporter <value>` are dropped without consuming an adjacent flag.

* fix(installing.commands): drop negated/value forms of always-injected flags

`collectForwardedFlags` only matched the exact positive tokens
`--frozen-lockfile` and `--ignore-manifest-check`, so a user typing
`pnpm install --no-frozen-lockfile` (or `--frozen-lockfile=false`)
forwarded the negation to pacquet, which then saw both our injected
`--frozen-lockfile` and the user's `--no-frozen-lockfile` and crashed
clap with "unexpected argument".

Match every shape the user can write the same flag in: positive,
`--no-` negated, and any `=value` form. Can't blindly strip `--no-`
either way — pacquet has flags whose literal name starts with `no-`
(`--no-runtime`, `--no-optional`); those must still forward.

The user's `--no-frozen-lockfile` intent is honored upstream — pnpm
did a fresh resolve before delegating; pacquet's role here is just
lockfile-driven materialization, which is always frozen.

* fix(installing.commands): match positionals by index, hide reporter from dropped-flags warning

`collectForwardedFlags` matched positionals via `new Set(argv.remain)`,
which strips by value: a flag value that happened to equal a
positional token (e.g. `pnpm install --node-linker install`) was
wrongly dropped from the forwarded list, costing pacquet the value
of `--node-linker`. Walk `argv.original` with a subsequence pointer
into `argv.remain` so only the actual positional indexes get skipped.

`collectDroppedFlags` still surfaced `--reporter foo` / `--reporter=foo`
in the "may not be honored" warning on `add`/`update`/`dedupe`, but
pnpm honors reporter selection itself before delegation — so the
warning was misleading. Route both helpers through the same
`isAlwaysInjected` check and consume `--reporter` and its value the
same way `collectForwardedFlags` already does.
This commit is contained in:
Zoltan Kochan
2026-05-21 14:00:32 +02:00
committed by GitHub
parent 0dd1ec445c
commit 881a86541b
6 changed files with 170 additions and 45 deletions

View File

@@ -0,0 +1,5 @@
---
"pnpm": patch
---
Fixed `pnpm up` (and `pnpm add` / `pnpm remove`) failing with `pacquet_package_manager::outdated_lockfile` when pacquet is declared in `configDependencies`. pnpm now passes `--ignore-manifest-check` to pacquet so its `--frozen-lockfile` check doesn't fire against the (pre-mutation) `package.json` pnpm hasn't written yet [#11797](https://github.com/pnpm/pnpm/issues/11797). Requires a pacquet release that supports the flag — bump `PACQUET_VERSION` in the e2e tests once it ships.

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.commands": patch
"pnpm": patch
---
When the install engine is delegated to pacquet via `configDependencies`, the user's CLI flags passed to `pnpm install` (e.g. `--no-runtime`, `--prod`, `--dev`, `--no-optional`, `--node-linker`, `--cpu`/`--os`/`--libc`, `--offline`, `--prefer-offline`) are now forwarded to pacquet's `install` subcommand verbatim. Previously pacquet was invoked with a fixed argument list, so flags like `--no-runtime` were silently dropped. Flag forwarding is gated on the command being `install`/`i`; `add`, `update`, and `dedupe` still don't forward (their flag surface doesn't line up with pacquet's `install`).

View File

@@ -350,7 +350,9 @@ export type InstallCommandOptions = Pick<Config,
| 'selectedProjectsGraph'
> & CreateStoreControllerOptions & Partial<Pick<Config, 'globalPkgDir'>> & {
argv: {
cooked?: string[]
original: string[]
remain?: string[]
}
fixLockfile?: boolean
frozenLockfileIfExists?: boolean
@@ -388,6 +390,7 @@ export async function handler (opts: InstallCommandOptions & { _calledFromLink?:
include,
includeDirect: include,
fetchFullMetadata: getFetchFullMetadata(opts),
isInstallCommand: true,
}
if (opts.resolutionOnly) {
installDepsOptions.lockfileOnly = true

View File

@@ -127,7 +127,9 @@ export type InstallDepsOptions = Pick<Config,
> & Partial<Pick<Config, 'ci'>>
& CreateStoreControllerOptions & {
argv: {
cooked?: string[]
original: string[]
remain?: string[]
}
allowNew?: boolean
forceFullResolution?: boolean
@@ -160,6 +162,13 @@ export type InstallDepsOptions = Pick<Config,
rebuildHandler?: CommandHandler
pnpmfile: string[]
packageVulnerabilityAudit?: PackageVulnerabilityAudit
/**
* `true` when this call originated from `pnpm install` (or `pnpm i`),
* `false`/`undefined` for `add`, `update`, `dedupe`, etc. Used to gate
* which pnpm CLI flags are safe to forward to pacquet's `install`
* subcommand — see `runPacquet.ts`'s `noRuntime` opt.
*/
isInstallCommand?: boolean
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds'>>
export async function installDeps (
@@ -216,7 +225,8 @@ export async function installDeps (
? makeRunPacquet({
lockfileDir: opts.lockfileDir ?? opts.dir,
packageName: pacquetConfigDepName,
argv: opts.argv.original,
argv: { original: opts.argv.original, remain: opts.argv.remain ?? [] },
isInstallCommand: opts.isInstallCommand === true,
})
: undefined
const includeDirect = opts.includeDirect ?? {

View File

@@ -27,27 +27,43 @@ export interface MakeRunPacquetOpts {
*/
packageName: 'pacquet' | '@pnpm/pacquet'
/**
* The user's original `pnpm` argv (`process.argv.slice(2)`). Not
* forwarded to pacquet — we only inspect it to warn about flags
* The parsed pnpm argv from `@pnpm/cli.parse-cli-args` — `original`
* preserves the user's exact tokens (so `--key=value` stays joined,
* which pacquet's `--config.<key>=<value>` parser requires), and
* `remain` lists the positionals (the `install`/`i` command token
* among them). When `isInstallCommand` is true we forward
* `original` minus positionals to pacquet's own `install`
* subcommand; otherwise we only inspect it to warn about flags
* pacquet won't see.
*/
argv: string[]
argv: {
original: string[]
remain: string[]
}
/**
* `true` when the user invoked `pnpm install` (or `pnpm i`). Gates
* flag forwarding: pacquet's `install` subcommand mirrors pnpm's
* surface closely enough that the user's flags are safe to pass
* along on that command, but not from `add`/`update`/`dedupe` (whose
* own flag surface doesn't line up with pacquet's `install`).
*/
isInstallCommand: boolean
}
/**
* Build the install-engine callback `mutateModules` invokes when
* `configDependencies` declares pacquet. Returns `undefined` when no
* pacquet binary is on disk — the caller falls back to the JS path in
* that case.
* `configDependencies` declares pacquet.
*
* The callback spawns the pacquet binary installed under
* `node_modules/.pnpm-config/pacquet` and forwards the user's own
* pnpm CLI flags. Pacquet's NDJSON stderr is parsed line-by-line and
* the valid JSON records are re-emitted on pnpm's global
* `streamParser` so `@pnpm/cli.default-reporter` renders pacquet's
* events the same way it renders pnpm's own. Non-JSON stderr lines
* (panic backtraces, unexpected diagnostics) are forwarded to the
* real stderr verbatim so they reach the user.
* `node_modules/.pnpm-config/pacquet`. From `pnpm install`/`pnpm i` it
* forwards the user's own pnpm CLI flags to pacquet's `install`
* subcommand; from `add`/`update`/`dedupe` it doesn't forward (warning
* instead). Pacquet's NDJSON stderr is parsed line-by-line and the
* valid JSON records are re-emitted on pnpm's global `streamParser` so
* `@pnpm/cli.default-reporter` renders pacquet's events the same way it
* renders pnpm's own. Non-JSON stderr lines (panic backtraces,
* unexpected diagnostics) are forwarded to the real stderr verbatim so
* they reach the user.
*/
/** Args the deps-installer passes per pacquet invocation. */
export interface RunPacquetCallOpts {
@@ -67,15 +83,27 @@ export interface RunPacquetCallOpts {
export function makeRunPacquet (opts: MakeRunPacquetOpts): (callOpts?: RunPacquetCallOpts) => Promise<void> {
return async (callOpts) => {
const pacquetBin = resolvePacquetBin(opts.lockfileDir, opts.packageName)
// Always the same fixed args. We don't forward pnpm's CLI flags
// even though pacquet's `install` subcommand mirrors most of them:
// pnpm has commands like `add` and `update` that carry flags
// pacquet's `install` doesn't recognize (e.g., `--save-dev`,
// `--save-peer`), and clap would reject them. The settings users
// care about live in `pnpm-workspace.yaml` / `.npmrc`, which
// pacquet reads on its own.
const args = ['--reporter=ndjson', 'install', '--frozen-lockfile']
const droppedFlags = collectDroppedFlags(opts.argv)
// From `pnpm install`/`pnpm i` we forward the user's flags through to
// pacquet's own `install` subcommand verbatim — pacquet mirrors pnpm's
// surface closely enough on that command that they're safe to pass
// along. From `add`/`update`/`dedupe` we don't forward anything: those
// commands carry flags pacquet's `install` doesn't recognize
// (`--save-dev`, `--save-peer`, etc.) which clap would reject. Either
// way pacquet picks up the settings users care about from
// `pnpm-workspace.yaml` / `.npmrc` on its own, so a non-install
// delegation isn't broken by the omission.
const forwardedFlags = opts.isInstallCommand ? collectForwardedFlags(opts.argv) : []
// `--ignore-manifest-check` tells pacquet to skip its per-importer
// `package.json` ↔ `pnpm-lock.yaml` freshness gate. pnpm just
// resolved and wrote the lockfile itself; on `pnpm up` / `add` /
// `remove` the manifest on disk is still the pre-mutation copy
// (pnpm writes it after `mutateModules` returns), so pacquet's own
// check would always fire here. See
// https://github.com/pnpm/pnpm/issues/11797. The flag is narrow
// (only the manifest check); settings drift like `overrides` is
// still enforced and was already re-validated by pnpm.
const args = ['--reporter=ndjson', 'install', '--frozen-lockfile', '--ignore-manifest-check', ...forwardedFlags]
const droppedFlags = opts.isInstallCommand ? [] : collectDroppedFlags(opts.argv)
if (droppedFlags.length > 0) {
logger.warn({
message: `The following CLI flags are not forwarded to pacquet and may not be honored: ${droppedFlags.join(' ')}. Move the equivalent settings into pnpm-workspace.yaml (or .npmrc for auth/registry) if pacquet needs them.`,
@@ -154,24 +182,97 @@ function resolvePacquetBin (lockfileDir: string, packageName: 'pacquet' | '@pnpm
}
/**
* Pull the CLI flags out of pnpm's argv so we can warn about them
* before pacquet runs. We don't forward any of them — pacquet always
* gets `install --frozen-lockfile --reporter=ndjson` — but most are
* handled by pnpm itself before delegation (`--save-dev` rewrites
* `package.json`, `--filter` selects projects, etc.) so listing them
* to the user makes the "not forwarded" surface concrete.
* From `pnpm install`/`pnpm i`, return everything in argv that should
* ride along to pacquet's own `install` subcommand. Drops the
* positionals nopt classified (`install` / `i`, plus anything users
* typed positionally) since pacquet's `install` doesn't accept any —
* leaving them in produces `error: unexpected argument 'install'
* found`. Pacquet's clap parser walks the same `--prod`, `--dev`,
* `--no-optional`, `--no-runtime`, `--node-linker`, `--offline`,
* `--prefer-offline`, `--cpu`, `--os`, `--libc`, `--frozen-lockfile`
* surface pnpm itself accepts on `install`, so the flags don't need
* reshaping.
*
* Flags we explicitly emit ourselves (`--frozen-lockfile`,
* `--reporter=ndjson`) are filtered out: they're honored, so warning
* about them would be misleading. `--config.*` is filtered too —
* those configure pnpm's runtime and aren't intended for the install
* engine.
* Flags we always inject ourselves (`--frozen-lockfile`,
* `--ignore-manifest-check`) are dropped in every form the user can
* type them — positive (`--frozen-lockfile`), negated
* (`--no-frozen-lockfile`), and any `=value` form. Pacquet's clap
* defines these as plain `#[clap(long)] bool` flags, so a duplicate
* `--frozen-lockfile` or a conflicting `--no-frozen-lockfile`
* crashes the parser with "used multiple times" / "unexpected
* argument". The user's `--no-frozen-lockfile` intent is already
* honored upstream (pnpm did a fresh resolve before delegating);
* pacquet's role here is just lockfile-driven materialization.
*
* `--reporter` is stripped in any form (`--reporter=foo`,
* `--reporter foo`): pacquet's `reporter` is a clap value option
* with last-value-wins semantics, so a user-supplied value would
* override our `--reporter=ndjson` and break the
* NDJSON-to-streamParser plumbing the default reporter relies on.
*/
function collectDroppedFlags (argv: string[]): string[] {
return argv.filter((arg) => {
if (!arg.startsWith('-')) return false
if (arg === '--frozen-lockfile' || arg === '--reporter=ndjson') return false
if (arg.startsWith('--config.')) return false
return true
})
function collectForwardedFlags (argv: { original: string[], remain: string[] }): string[] {
const result: string[] = []
// `argv.remain` is the ordered subsequence of positionals nopt
// extracted from `original`. Match by index rather than by value so
// an option's value that happens to equal a positional (e.g.
// `--node-linker install`) isn't mistaken for the positional itself.
let positionalIdx = 0
for (let i = 0; i < argv.original.length; i++) {
const arg = argv.original[i]
if (positionalIdx < argv.remain.length && arg === argv.remain[positionalIdx]) {
positionalIdx++
continue
}
if (isAlwaysInjected(arg)) continue
if (arg.startsWith('--reporter=')) continue
if (arg === '--reporter') {
// Consume the next token as the reporter value (`--reporter foo`).
i++
continue
}
result.push(arg)
}
return result
}
const ALWAYS_INJECTED_FLAGS = ['frozen-lockfile', 'ignore-manifest-check'] as const
function isAlwaysInjected (arg: string): boolean {
for (const name of ALWAYS_INJECTED_FLAGS) {
if (arg === `--${name}` || arg === `--no-${name}`) return true
if (arg.startsWith(`--${name}=`) || arg.startsWith(`--no-${name}=`)) return true
}
return false
}
/**
* From a non-install command (`add`, `update`, `dedupe`, ...), pull the
* CLI flags out of pnpm's argv so we can warn that pacquet won't see
* them. They're still handled by pnpm itself before delegation
* (`--save-dev` rewrites `package.json`, `--filter` selects projects,
* etc.) so listing them to the user makes the "not forwarded" surface
* concrete.
*
* Flags pnpm itself honors before delegation are filtered out —
* warning about them would be misleading: `--frozen-lockfile` and
* `--ignore-manifest-check` in every shape (positive / negated /
* `=value`); `--reporter` in every shape (`--reporter=foo`,
* `--reporter foo`); and `--config.*` (configures pnpm's runtime,
* not the install engine).
*/
function collectDroppedFlags (argv: { original: string[] }): string[] {
const result: string[] = []
for (let i = 0; i < argv.original.length; i++) {
const arg = argv.original[i]
if (!arg.startsWith('-')) continue
if (isAlwaysInjected(arg)) continue
if (arg.startsWith('--config.')) continue
if (arg.startsWith('--reporter=')) continue
if (arg === '--reporter') {
i++
continue
}
result.push(arg)
}
return result
}

View File

@@ -11,7 +11,7 @@ import { execPnpm, execPnpmSync } from '../utils/index.js'
// version known to ship the `configDependencies` integration surface this
// PR depends on; tests are gated on the public registry being reachable.
const PUBLIC_REGISTRY = '--config.registry=https://registry.npmjs.org/'
const PACQUET_VERSION = '0.2.2-9'
const PACQUET_VERSION = '0.2.2'
// Each test runs two or three installs against the public registry; raise
// the per-test timeout above jest's 5s default to allow for cold caches.
@@ -126,9 +126,9 @@ test.skip('`pnpm update <pkg>` resolves a new version with pnpm and materializes
// Skipped until pacquet ships a release built with the updated
// `generate-packages.mjs` (this PR's change) so the `@pnpm/pacquet`
// scoped alias actually exists on npm. The pinned `0.2.2-9` doesn't
// publish that mirror yet. Re-enable when the next pacquet release
// ships under both names.
// scoped alias actually exists on npm. The pinned PACQUET_VERSION
// above doesn't publish that mirror yet. Re-enable when the next
// pacquet release ships under both names.
test.skip('the `@pnpm/pacquet` scoped alias is recognized in configDependencies', async () => {
await prepareWithPacquet({
manifest: { dependencies: { 'is-positive': '3.1.0' } },