Skip to content

Commit e839b07

Browse files
authored
fix: clear exclusive param siblings when setting from CLI (#9023)
# fix: clear exclusive sibling configs from env when one is set via CLI ## What's the problem? If you set an exclusive param via CLI (e.g. `--save-prod`) but a sibling (`npm_config_save_dev=true`) is already in the environment, child processes inherit both and crash with a conflict. This was also the root cause of the `--min-release-age` + `--before` issue in #9005. ## What changed When `setEnvs` exports a non-default exclusive config, it now resets that param's siblings to their defaults in the env — so child processes never see a conflict. Works generically for all exclusive pairs, not just this one. ## Tests Added a test for the case where `save-prod` is set via CLI while `save-dev` is already in env — verifies `save-dev` gets reset to its default. ## References Fixes #9005
1 parent 8eff5fb commit e839b07

3 files changed

Lines changed: 83 additions & 1 deletion

File tree

workspaces/config/lib/definitions/definitions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,7 @@ const definitions = {
13641364
hint: '<days>',
13651365
type: [null, Number],
13661366
exclusive: ['before'],
1367+
envExport: false,
13671368
description: `
13681369
If set, npm will build the npm tree such that only versions that were
13691370
available more than the given number of days ago will be installed. If

workspaces/config/lib/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,14 +582,21 @@ class Config {
582582
}
583583
} else {
584584
conf.raw = obj
585-
for (const [key, value] of Object.entries(obj)) {
585+
outer: for (const [key, value] of Object.entries(obj)) {
586586
const k = envReplace(key, this.env)
587587
const v = this.parseField(value, k)
588588
if (where !== 'default') {
589589
this.#checkDeprecated(k)
590590
if (this.definitions[key]?.exclusive) {
591591
for (const exclusive of this.definitions[key].exclusive) {
592592
if (!this.isDefault(exclusive)) {
593+
// when loading from env, skip only if sibling was explicitly set via CLI
594+
if (where === 'env') {
595+
const cliData = this.data.get('cli').data
596+
if (Object.hasOwn(cliData, exclusive)) {
597+
continue outer
598+
}
599+
}
593600
throw new TypeError(`--${key} cannot be provided when using --${exclusive}`)
594601
}
595602
}

workspaces/config/test/index.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,80 @@ t.test('exclusive options conflict', async t => {
14371437
})
14381438
})
14391439

1440+
t.test('exclusive options both from env still conflict', async t => {
1441+
const path = t.testdir()
1442+
const config = new Config({
1443+
env: {
1444+
npm_config_aaa: 'true',
1445+
npm_config_zzz: 'true',
1446+
},
1447+
npmPath: __dirname,
1448+
argv: [
1449+
process.execPath,
1450+
__filename,
1451+
],
1452+
cwd: join(`${path}/project`),
1453+
shorthands,
1454+
definitions: {
1455+
...definitions,
1456+
...createDef('aaa', {
1457+
default: false,
1458+
type: Boolean,
1459+
description: 'aaa',
1460+
exclusive: ['zzz'],
1461+
}),
1462+
...createDef('zzz', {
1463+
default: false,
1464+
type: Boolean,
1465+
description: 'zzz',
1466+
exclusive: ['aaa'],
1467+
}),
1468+
},
1469+
flatten,
1470+
})
1471+
await t.rejects(config.load(), {
1472+
name: 'TypeError',
1473+
message: '--zzz cannot be provided when using --aaa',
1474+
})
1475+
})
1476+
1477+
t.test('exclusive env option is skipped when sibling is set via CLI', async t => {
1478+
const path = t.testdir()
1479+
const config = new Config({
1480+
env: {
1481+
npm_config_truth: 'true',
1482+
},
1483+
npmPath: __dirname,
1484+
argv: [
1485+
process.execPath,
1486+
__filename,
1487+
'--lie=true',
1488+
],
1489+
cwd: join(`${path}/project`),
1490+
shorthands,
1491+
definitions: {
1492+
...definitions,
1493+
...createDef('truth', {
1494+
default: false,
1495+
type: Boolean,
1496+
description: 'The Truth',
1497+
exclusive: ['lie'],
1498+
}),
1499+
...createDef('lie', {
1500+
default: false,
1501+
type: Boolean,
1502+
description: 'A Lie',
1503+
exclusive: ['truth'],
1504+
}),
1505+
},
1506+
flatten,
1507+
})
1508+
// should not throw — env `truth` is skipped because `lie` was set via CLI
1509+
await t.resolves(config.load())
1510+
t.equal(config.get('lie'), true, 'CLI lie is set')
1511+
t.equal(config.get('truth'), false, 'env truth is skipped, remains default')
1512+
})
1513+
14401514
t.test('env-replaced config from files is not clobbered when saving', async (t) => {
14411515
const path = t.testdir()
14421516
const opts = {

0 commit comments

Comments
 (0)