From bcd337fa7221f8b96f210b5042217d5134ddfea7 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 6 May 2026 14:28:06 +0200 Subject: [PATCH] fix(cli): honor --pm-on-fail when combined with --help / --version (#11489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../pm-on-fail-survives-help-version.md | 6 ++ cli/parse-cli-args/src/index.ts | 23 +++++++- cli/parse-cli-args/test/index.ts | 58 +++++++++++++++++++ pnpm/test/packageManagerCheck.test.ts | 28 +++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 .changeset/pm-on-fail-survives-help-version.md diff --git a/.changeset/pm-on-fail-survives-help-version.md b/.changeset/pm-on-fail-survives-help-version.md new file mode 100644 index 0000000000..99f6dcb45d --- /dev/null +++ b/.changeset/pm-on-fail-survives-help-version.md @@ -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). diff --git a/cli/parse-cli-args/src/index.ts b/cli/parse-cli-args/src/index.ts index 3863263b9a..184a7c8e82 100644 --- a/cli/parse-cli-args/src/index.ts +++ b/cli/parse-cli-args/src/index.ts @@ -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 { + const result: Record = {} + for (const key of Object.keys(opts.universalOptionsTypes)) { + if (!(key in noptExploratoryResults)) continue + const renamed = opts.renamedOptions?.[key] ?? key + result[renamed] = (noptExploratoryResults as Record)[key] + } + return result + } + const types = { ...opts.universalOptionsTypes, ...opts.getTypesByCommandName(commandName), diff --git a/cli/parse-cli-args/test/index.ts b/cli/parse-cli-args/test/index.ts index bb6239da3f..420d5af62d 100644 --- a/cli/parse-cli-args/test/index.ts +++ b/cli/parse-cli-args/test/index.ts @@ -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, diff --git a/pnpm/test/packageManagerCheck.test.ts b/pnpm/test/packageManagerCheck.test.ts index 2ccbae820b..84d42f4132 100644 --- a/pnpm/test/packageManagerCheck.test.ts +++ b/pnpm/test/packageManagerCheck.test.ts @@ -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') +})