From b7652caf1c73318b2620e03e54403d68d07c3644 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Thu, 22 Oct 2026 18:32:35 +0300 Subject: [PATCH] fix: keep npx command flags after negated switches like --no npx treated --no (expanded to --no-yes) and other negated switches such as --no-color as options that take a value, so the command name was consumed as that value and the command's own flags were parsed by npm. A negated switch now takes the next argument only when npm's parser reads it as the switch's value. --- bin/npx-cli.js | 17 +++++++++++++++- test/bin/npx-cli.js | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/bin/npx-cli.js b/bin/npx-cli.js index e2e1b87906abe..296bf3edb17fa 100755 --- a/bin/npx-cli.js +++ b/bin/npx-cli.js @@ -24,6 +24,7 @@ const removed = new Set([ ...removedOpts, ]) +const nopt = require('nopt') const { definitions, shorthands } = require('@npmcli/config/lib/definitions') const npmSwitches = Object.entries(definitions) .filter(([, { type }]) => type === Boolean || @@ -43,6 +44,17 @@ const switches = new Set([ 'h', ]) +// a negated switch (--no-color, or --no expanded to --no-yes) only takes the +// next argument when npm reads it as the value, as in --no-color always +const readsNegatedValue = (negation, next) => { + if (next === undefined) { + return false + } + const key = negation.slice(3) + const { argv } = nopt({ [key]: definitions[key].type }, {}, [`--${negation}`, next], 0) + return argv.remain.length === 0 +} + // things that do take a value const opts = new Set([ ...removedOpts, @@ -105,8 +117,11 @@ for (i = 3; i < process.argv.length; i++) { i-- } + const negated = key.startsWith('no-') && npmSwitches.includes(key.slice(3)) + if (v.length === 0 && !switches.has(key) && - (opts.has(key) || !/^-/.test(process.argv[i + 1]))) { + (opts.has(key) || !/^-/.test(process.argv[i + 1])) && + (!negated || readsNegatedValue(key, process.argv[i + 1]))) { // value will be next argument, skip over it. if (removed.has(key)) { // also remove the value for the cut key. diff --git a/test/bin/npx-cli.js b/test/bin/npx-cli.js index 67a8d3319fc18..4fa3440dbd4d0 100644 --- a/test/bin/npx-cli.js +++ b/test/bin/npx-cli.js @@ -38,6 +38,53 @@ t.test('npx --x=y --no-install foo -z -> npm exec --x=y -- foo -z', async t => { t.strictSame(argv, ['node', npm, 'exec', '--x=y', '--yes=false', '--', 'foo', '-z']) }) +t.test('npx --no foo -z -> npm exec --no-yes -- foo -z', async t => { + const { argv } = mockNpx(t, ['node', npx, '--no', 'foo', '-z']) + t.strictSame(argv, ['node', npm, 'exec', '--no-yes', '--', 'foo', '-z']) +}) + +t.test('negated switches do not take the command as a value', async t => { + const { argv } = mockNpx(t, ['node', npx, '--no-color', '--local', 'foo', '--version']) + t.strictSame(argv, ['node', npm, 'exec', '--no-color', '--no-global', '--', 'foo', '--version']) +}) + +t.test('negated switches keep values that npm reads for them', async t => { + const { argv } = mockNpx(t, [ + 'node', + npx, + '--no=false', + '--no-color', + 'always', + '--no-workspaces', + 'null', + '--no-browser', + 'firefox', + 'foo', + '-z', + ]) + t.strictSame(argv, [ + 'node', + npm, + 'exec', + '--no-yes', + 'false', + '--no-color', + 'always', + '--no-workspaces', + 'null', + '--no-browser', + 'firefox', + '--', + 'foo', + '-z', + ]) +}) + +t.test('npx --no -> npm exec --no-yes', async t => { + const { argv } = mockNpx(t, ['node', npx, '--no']) + t.strictSame(argv, ['node', npm, 'exec', '--no-yes']) +}) + t.test('transform renamed options into proper values', async t => { const { argv } = mockNpx(t, ['node', npx, '-y', '--shell=bash', '-p', 'foo', '-c', 'asdf']) t.strictSame(argv, [