fix(cli): honor --pm-on-fail when combined with --help / --version (#11489)

The CLI argument parser short-circuits `--help` and `--version` and was discarding every other parsed option in the process — including universal flags like `--pm-on-fail`. So `pnpm audit --pm-on-fail=ignore --help` and `pnpm --pm-on-fail=ignore --version` failed with the strict `packageManager` mismatch error instead of doing what was asked. Users had no documented way out: the suggested escape hatch in the error message itself didn't work.

The fix plucks universal options back out of the exploratory `nopt` parse and surfaces them through both short-circuits. They were already typed correctly there; only the regular per-command parse adds command-specific options. Command-specific options (e.g. `--frozen-lockfile`) stay dropped, since the matching command isn't being executed.

Closes [#11487](https://github.com/pnpm/pnpm/issues/11487).
This commit is contained in:
Zoltan Kochan
2026-05-06 14:28:06 +02:00
parent 27425d7bfc
commit 81161d51c6
4 changed files with 114 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/cli.parse-cli-args": patch
"pnpm": patch
---
`--pm-on-fail=ignore` (and other universal options like `--loglevel`, `--reporter`) is now honored when combined with `--help` or `--version`. Previously the CLI argument parser short-circuited those flags before universal options were preserved, so `pnpm audit --pm-on-fail=ignore --help` and `pnpm --pm-on-fail=ignore --version` reported the strict packageManager mismatch instead of running the requested action [#11487](https://github.com/pnpm/pnpm/issues/11487).

View File

@@ -73,6 +73,7 @@ export async function parseCliArgs (
argv: noptExploratoryResults.argv,
cmd: null,
options: {
...pickUniversalOptions(),
version: true,
},
params: noptExploratoryResults.argv.remain,
@@ -87,13 +88,33 @@ export async function parseCliArgs (
return {
argv: noptExploratoryResults.argv,
cmd: 'help',
options: {},
options: pickUniversalOptions(),
params: noptExploratoryResults.argv.remain,
unknownOptions: new Map(),
fallbackCommandUsed: false,
}
}
// The --help and --version short-circuits skip the per-command nopt
// parse, so we still need to surface universal options the user typed
// alongside them — most importantly --pm-on-fail, which gates the
// packageManager / devEngines.packageManager check (#11487). Universal
// options were already typed and parsed by the exploratory nopt call,
// so we just pluck them back out and apply the same renamedOptions
// mapping the regular parse path uses (e.g. --prefix → dir), so
// consumers see consistent option names regardless of which path
// produced the result. Command-specific options are intentionally
// dropped; they belong to a command we are not running.
function pickUniversalOptions (): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const key of Object.keys(opts.universalOptionsTypes)) {
if (!(key in noptExploratoryResults)) continue
const renamed = opts.renamedOptions?.[key] ?? key
result[renamed] = (noptExploratoryResults as Record<string, unknown>)[key]
}
return result
}
const types = {
...opts.universalOptionsTypes,
...opts.getTypesByCommandName(commandName),

View File

@@ -185,6 +185,64 @@ test('no command', async () => {
expect(cmd).toBeNull()
})
// Regression for #11487 — --pm-on-fail must reach the consumer even when
// short-circuited by --help, otherwise users can't bypass the
// packageManager check just to read help text for a stale-pinned project.
test('universal options typed in the exploratory parse survive the --help short-circuit', async () => {
const { cmd, options } = await parseCliArgs({
...DEFAULT_OPTS,
universalOptionsTypes: { 'pm-on-fail': ['ignore', 'warn', 'error'] },
}, ['install', '--pm-on-fail=ignore', '--help'])
expect(cmd).toBe('help')
expect(options).toMatchObject({ 'pm-on-fail': 'ignore' })
})
test('universal options typed in the exploratory parse survive the --version short-circuit', async () => {
const { cmd, options } = await parseCliArgs({
...DEFAULT_OPTS,
universalOptionsTypes: { 'pm-on-fail': ['ignore', 'warn', 'error'] },
}, ['--pm-on-fail=ignore', '--version'])
expect(cmd).toBeNull()
expect(options).toMatchObject({ version: true, 'pm-on-fail': 'ignore' })
})
test('command-specific options do NOT leak through the --help short-circuit', async () => {
// We're not executing the command, so its options shouldn't appear in
// cliOptions and accidentally influence config (e.g. --frozen-lockfile
// shouldn't bleed into the help path).
const { cmd, options } = await parseCliArgs({
...DEFAULT_OPTS,
getTypesByCommandName: (name) => name === 'install' ? { 'frozen-lockfile': Boolean } : {},
}, ['install', '--frozen-lockfile', '--help'])
expect(cmd).toBe('help')
expect(options).not.toHaveProperty(['frozen-lockfile'])
})
// renamedOptions (e.g. pnpm's --prefix → dir) must be applied in the
// short-circuit too, otherwise consumers downstream receive inconsistent
// keys depending on whether --help/--version was the entry path.
test('renamedOptions are applied to picked universal options in --help short-circuit', async () => {
const { cmd, options } = await parseCliArgs({
...DEFAULT_OPTS,
universalOptionsTypes: { prefix: String },
renamedOptions: { prefix: 'dir' },
}, ['install', '--prefix=/foo', '--help'])
expect(cmd).toBe('help')
expect(options).toMatchObject({ dir: '/foo' })
expect(options).not.toHaveProperty(['prefix'])
})
test('renamedOptions are applied to picked universal options in --version short-circuit', async () => {
const { cmd, options } = await parseCliArgs({
...DEFAULT_OPTS,
universalOptionsTypes: { prefix: String },
renamedOptions: { prefix: 'dir' },
}, ['--prefix=/foo', '--version'])
expect(cmd).toBeNull()
expect(options).toMatchObject({ version: true, dir: '/foo' })
expect(options).not.toHaveProperty(['prefix'])
})
test('use command-specific shorthands', async () => {
const { options } = await parseCliArgs({
...DEFAULT_OPTS,

View File

@@ -407,3 +407,31 @@ test('pmOnFail=ignore set in pnpm-workspace.yaml bypasses the devEngines.package
expect(status).toBe(0)
expect(stderr.toString()).not.toContain('0.0.1')
})
// Regression for #11487. The --version and --help short-circuits in
// parse-cli-args used to drop every parsed option, so `--pm-on-fail=ignore`
// silently disappeared whenever it was combined with `--version` or
// `--help` — leaving users with no way to opt out of the strict
// packageManager check just to read help or check the running version.
test.each([
[['--pm-on-fail=ignore', '--version']],
[['--version', '--pm-on-fail=ignore']],
[['audit', '--pm-on-fail=ignore', '--help']],
[['audit', '--help', '--pm-on-fail=ignore']],
])('--pm-on-fail=ignore is honored when combined with --version/--help: %p', (args) => {
prepare({
packageManager: 'pnpm@0.0.1',
devEngines: {
packageManager: {
name: 'pnpm',
version: '0.0.1',
onFail: 'error',
},
},
})
const { status, stderr } = execPnpmSync(args)
expect(status).toBe(0)
expect(stderr.toString()).not.toContain('configured to use 0.0.1')
})