fix: parse bare color as boolean flag (#12162)

* fix: parse bare color as boolean flag

Bare --color was treated as an enum-only option, so nopt could
consume the following flag as its value. This prevented command-specific
shorthands such as run --parallel from expanding when --color preceded
them.

Accept boolean values for the color option, and add parser/config
regression coverage for the shorthand case.

Co-authored-by: OpenAI Codex <codex@openai.com>

* test: remove tautological color type metadata test

Per review feedback, the assertion only restated the type metadata; the
behavior is already covered by the parser regression test and the
color-normalization test.

* fix: keep with current dispatch working after a boolean global flag

Bare `--color` now parses as a boolean flag, which enables forms like
`pnpm --color with current <cmd>`. The `with current` rewrite skipped any
occurrence whose preceding token was a long flag without `=`, assuming it
consumed `with` as its value. For boolean flags that is wrong, so the
lookup returned -1 and pnpm threw MISSING_WITH_CURRENT_CMD.

Skip only when the preceding long option actually takes a value, based on
the option type metadata; boolean flags and `--no-` negations no longer
hide the command.

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
minbang
2026-06-08 22:04:19 +09:00
committed by GitHub
parent b4cc602b6d
commit 027196babe
5 changed files with 73 additions and 6 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fixed bare `--color` so it does not consume the following CLI flag, allowing command shorthands like `--parallel` to expand correctly and forms like `pnpm --color with current <command>` to dispatch the inner command instead of failing with `MISSING_WITH_CURRENT_CMD`.

View File

@@ -263,6 +263,40 @@ test('use command-specific shorthands', async () => {
expect(options).toHaveProperty(['dev'])
})
test('bare --color does not consume a following command-specific shorthand', async () => {
const { cmd, options, params } = await parseCliArgs({
...DEFAULT_OPTS,
getTypesByCommandName: (commandName: string) => {
if (commandName === 'run') {
return {
recursive: Boolean,
sort: Boolean,
stream: Boolean,
'workspace-concurrency': Number,
}
}
return {}
},
shorthandsByCommandName: {
run: {
parallel: ['--workspace-concurrency=Infinity', '--no-sort', '--stream', '--recursive'],
},
},
universalOptionsTypes: {
color: [Boolean, 'always', 'auto', 'never'],
recursive: Boolean,
},
}, ['--recursive', '--color', '--parallel', 'run', 'dev'])
expect(cmd).toBe('run')
expect(params).toStrictEqual(['dev'])
expect(options.color).toBe(true)
expect(options.recursive).toBe(true)
expect(options['workspace-concurrency']).toBe(Infinity)
expect(options.sort).toBe(false)
expect(options.stream).toBe(true)
})
test('command-specific shorthands override universal shorthands', async () => {
const { options } = await parseCliArgs({
...DEFAULT_OPTS,

View File

@@ -13,7 +13,7 @@ export const pnpmTypes = {
'child-concurrency': Number,
'merge-git-branch-lockfiles': Boolean,
'merge-git-branch-lockfiles-branch-pattern': Array,
color: ['always', 'auto', 'never'],
color: [Boolean, 'always', 'auto', 'never'],
'config-dir': String,
'dangerously-allow-all-builds': Boolean,
'deploy-all-files': Boolean,

View File

@@ -43,7 +43,7 @@ export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgsW
// any global flags the user put BEFORE `with` (e.g. `--dir`, `--filter`)
// are preserved.
if (result.cmd === 'with' && result.params[0] === 'current') {
const withIdx = findWithCurrentIndex(inputArgv)
const withIdx = findWithCurrentIndex(inputArgv, GLOBAL_OPTIONS)
if (withIdx < 0 || inputArgv.length - withIdx - 2 === 0) {
throw new PnpmError('MISSING_WITH_CURRENT_CMD',
'Missing command after "current". Usage: pnpm with current <command> [args...]')
@@ -63,14 +63,26 @@ export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgsW
* is the one. Good enough for realistic CLI usage — no pnpm option is
* expected to take the literal value `with`.
*/
function findWithCurrentIndex (argv: string[]): number {
function findWithCurrentIndex (argv: string[], optionTypes: Record<string, unknown>): number {
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] !== 'with' || argv[i + 1] !== 'current') continue
const prev = argv[i - 1]
// If the previous token is a long flag without an `=value` form, it may
// be consuming `with` as its value — skip this occurrence in that case.
if (prev != null && prev.startsWith('--') && !prev.includes('=')) continue
// A preceding long option that takes a value would consume `with` as its
// value, so this `with current` pair isn't the command — skip it. Boolean
// flags (e.g. `--color`) and `--no-` negations don't consume a value.
if (prev != null && longOptionConsumesValue(prev, optionTypes)) continue
return i
}
return -1
}
function longOptionConsumesValue (token: string, optionTypes: Record<string, unknown>): boolean {
if (!token.startsWith('--') || token.includes('=')) return false
const name = token.slice(2)
if (name.startsWith('no-')) return false
return !isBooleanType(optionTypes[name])
}
function isBooleanType (type: unknown): boolean {
return type === Boolean || (Array.isArray(type) && type.includes(Boolean))
}

View File

@@ -67,6 +67,21 @@ test('pnpm with forwards subsequent args to the child pnpm', () => {
expect(stdout.toString().trim()).toMatch(/^\d+\.\d+\.\d+/)
})
test('pnpm with current dispatches the inner command after a global boolean flag', () => {
prepare()
writeJsonFileSync('package.json', {
name: 'project',
version: '1.0.0',
})
for (const flag of ['--color', '--yes']) {
const { status, stdout } = execPnpmSync([flag, 'with', 'current', '--version'])
expect(status).toBe(0)
expect(stdout.toString().trim()).toMatch(/^\d+\.\d+\.\d+/)
}
})
test('pnpm with fails when no spec is provided', () => {
prepare()