diff --git a/.changeset/fuzzy-color-flags.md b/.changeset/fuzzy-color-flags.md new file mode 100644 index 0000000000..7f2ce72cb8 --- /dev/null +++ b/.changeset/fuzzy-color-flags.md @@ -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 ` to dispatch the inner command instead of failing with `MISSING_WITH_CURRENT_CMD`. diff --git a/cli/parse-cli-args/test/index.ts b/cli/parse-cli-args/test/index.ts index 451443ef2e..5126111c39 100644 --- a/cli/parse-cli-args/test/index.ts +++ b/cli/parse-cli-args/test/index.ts @@ -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, diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 6a0495bedd..02a3ee87cd 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -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, diff --git a/pnpm/src/parseCliArgs.ts b/pnpm/src/parseCliArgs.ts index 7d0d2090b3..ba6051e2c9 100644 --- a/pnpm/src/parseCliArgs.ts +++ b/pnpm/src/parseCliArgs.ts @@ -43,7 +43,7 @@ export async function parseCliArgs (inputArgv: string[]): Promise [args...]') @@ -63,14 +63,26 @@ export async function parseCliArgs (inputArgv: string[]): Promise): 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): 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)) +} diff --git a/pnpm/test/withCommand.test.ts b/pnpm/test/withCommand.test.ts index ac1984a59d..9cf1184115 100644 --- a/pnpm/test/withCommand.test.ts +++ b/pnpm/test/withCommand.test.ts @@ -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()