From 8daf4733d3a5c99ee80d3ae92d371aeb1cac86bf Mon Sep 17 00:00:00 2001 From: raazkhnl Date: Fri, 1 May 2026 07:28:07 +0545 Subject: [PATCH] fix: allow min-release-age in npmrc to coexist with --before in spawned subprocesses When the user has `min-release-age=N` in their `.npmrc`, the config flatten function derives a `before` date used by pacote. Whenever pacote spawns a child npm process (e.g. preparing a `git:` or `github:` dep), it forwards `--before=` to the child. The child then loads the same `.npmrc` and the previously declared mutual-exclusivity between `before` and `min-release-age` caused a hard configuration error. This makes the two options coexist: the `exclusive` constraints are removed and both flatten functions resolve to the earlier of the two effective dates, never widening the user's most conservative bound. The `min-release-age` flatten no longer mutates the per-source config object (the prior `obj.before = ...` / `delete obj['min-release-age']` mutations were vestigial and only masked the conflict at the parent level, not in spawned children). `min-release-age` is also added to the `params` arrays for `outdated` and `update` so it remains visible in their command help; it was previously displayed implicitly via the `before` exclusive grouping. Fixes: https://github.com/npm/cli/issues/9291 --- lib/commands/outdated.js | 1 + lib/commands/update.js | 1 + tap-snapshots/test/lib/docs.js.test.cjs | 38 ++++++--- .../config/lib/definitions/definitions.js | 21 +++-- workspaces/config/test/index.js | 82 +++++++++++++++++++ 5 files changed, 126 insertions(+), 17 deletions(-) diff --git a/lib/commands/outdated.js b/lib/commands/outdated.js index 9140cdbc9fea5..882ad2cc9d28a 100644 --- a/lib/commands/outdated.js +++ b/lib/commands/outdated.js @@ -31,6 +31,7 @@ class Outdated extends ArboristWorkspaceCmd { 'global', 'workspace', 'before', + 'min-release-age', ] #tree diff --git a/lib/commands/update.js b/lib/commands/update.js index ed1416d70c13e..a7fa14d8fcf24 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -21,6 +21,7 @@ class Update extends ArboristWorkspaceCmd { 'ignore-scripts', 'audit', 'before', + 'min-release-age', 'bin-links', 'fund', 'dry-run', diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index e7bad30e38ff4..15e7419c77556 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -341,7 +341,13 @@ If the requested version is a \`dist-tag\` and the given tag does not pass the will be used. For example, \`foo@latest\` might install \`foo@1.2\` even though \`latest\` is \`2.0\`. -This config cannot be used with: \`min-release-age\` +If \`before\` and \`min-release-age\` are both set in the same source, \`before\` +wins (an explicit absolute date overrides a relative window). Across +sources, the standard precedence applies (cli > env > project > user > +global), so a higher-priority source can always relax or override a +lower-priority one. + + #### \`bin-links\` @@ -1194,9 +1200,11 @@ are no versions available for the current set of dependencies, the command will error. This flag is a complement to \`before\`, which accepts an exact date instead -of a relative number of days. - -This config cannot be used with: \`before\` +of a relative number of days. The two may coexist (e.g. \`min-release-age\` in +your \`.npmrc\` is preserved when npm internally spawns a sub-process with +\`--before\` while preparing a \`git:\` or \`github:\` dependency); when both +apply, \`before\` wins within a single source and across sources the standard +precedence rules apply. This value is not exported to the environment for child processes. @@ -3985,9 +3993,9 @@ Options: [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] [--foreground-scripts] [--ignore-scripts] [--allow-directory ] [--allow-file ] [--allow-git ] -[--allow-remote ] [--no-audit] -[--before |--min-release-age ] [--no-bin-links] [--no-fund] -[--dry-run] [--cpu ] [--os ] [--libc ] +[--allow-remote ] [--no-audit] [--before ] +[--min-release-age ] [--no-bin-links] [--no-fund] [--dry-run] [--cpu ] +[--os ] [--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4253,9 +4261,9 @@ Options: [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] [--foreground-scripts] [--ignore-scripts] [--allow-directory ] [--allow-file ] [--allow-git ] -[--allow-remote ] [--no-audit] -[--before |--min-release-age ] [--no-bin-links] [--no-fund] -[--dry-run] [--cpu ] [--os ] [--libc ] +[--allow-remote ] [--no-audit] [--before ] +[--min-release-age ] [--no-bin-links] [--no-fund] [--dry-run] [--cpu ] +[--os ] [--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4829,7 +4837,7 @@ npm outdated [ ...] Options: [-a|--all] [--json] [-l|--long] [-p|--parseable] [-g|--global] [-w|--workspace [-w|--workspace ...]] -[--before |--min-release-age ] +[--before ] [--min-release-age ] -a|--all When running \`npm outdated\` and \`npm ls\`, setting \`--all\` will show @@ -4852,6 +4860,9 @@ Options: --before If passed to \`npm install\`, will rebuild the npm tree such that only + --min-release-age + If set, npm will build the npm tree such that only versions that were + Run "npm help outdated" for more info @@ -5991,7 +6002,7 @@ Options: [--omit [--omit ...]] [--include [--include ...]] [--strict-peer-deps] [--no-package-lock] [--foreground-scripts] -[--ignore-scripts] [--no-audit] [--before |--min-release-age ] +[--ignore-scripts] [--no-audit] [--before ] [--min-release-age ] [--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -6035,6 +6046,9 @@ Options: --before If passed to \`npm install\`, will rebuild the npm tree such that only + --min-release-age + If set, npm will build the npm tree such that only versions that were + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 991d219a3f459..b5c0bc704d5a6 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -292,7 +292,6 @@ const definitions = { default: null, hint: '', type: [null, Date], - exclusive: ['min-release-age'], description: ` If passed to \`npm install\`, will rebuild the npm tree such that only versions that were available **on or before** the given date are @@ -303,6 +302,12 @@ const definitions = { pass the \`--before\` filter, the most recent version less than or equal to that tag will be used. For example, \`foo@latest\` might install \`foo@1.2\` even though \`latest\` is \`2.0\`. + + If \`before\` and \`min-release-age\` are both set in the same source, + \`before\` wins (an explicit absolute date overrides a relative window). + Across sources, the standard precedence applies (cli > env > project > + user > global), so a higher-priority source can always relax or + override a lower-priority one. `, flatten, }), @@ -1409,7 +1414,6 @@ const definitions = { default: null, hint: '', type: [null, Number], - exclusive: ['before'], envExport: false, description: ` If set, npm will build the npm tree such that only versions that were @@ -1418,12 +1422,19 @@ const definitions = { command will error. This flag is a complement to \`before\`, which accepts an exact date - instead of a relative number of days. + instead of a relative number of days. The two may coexist (e.g. + \`min-release-age\` in your \`.npmrc\` is preserved when npm internally + spawns a sub-process with \`--before\` while preparing a \`git:\` or + \`github:\` dependency); when both apply, \`before\` wins within a + single source and across sources the standard precedence rules apply. `, flatten: (key, obj, flatOptions) => { - if (obj['min-release-age'] !== null) { + // If `before` is set in the same source, defer to it: an explicit + // absolute date overrides a relative window. Across sources, normal + // priority ordering means a higher-priority `before` will overwrite + // this `flatOptions.before` later in the flatten loop. + if (obj['min-release-age'] != null && obj.before == null) { flatOptions.before = new Date(Date.now() - (86400000 * obj['min-release-age'])) - obj.before = flatOptions.before } }, }), diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index 7a166047f4e48..a88619a4ddc7d 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1869,3 +1869,85 @@ t.test('before and min-release-age', async t => { t.ok(config.flat.before < Date.now(), 'before date is in the past not the future') t.equal(config.get('min-release-age'), 30, 'min-release-age config remains readable after flattening') }) + +// Regression test for https://github.com/npm/cli/issues/9291 +// pacote spawns child npm processes with `--before=` whenever it has a +// `before` option (which includes the case where the parent derived `before` +// from `min-release-age`). The child process then loads the user's npmrc, which +// still contains `min-release-age=N`. Previously this combination crashed +// because the two options were declared mutually exclusive. +t.test('min-release-age in npmrc coexists with --before from CLI (pacote spawn)', async t => { + const dir = t.testdir({ + '.npmrc': 'min-release-age=7', + }) + const cliBefore = new Date('2024-01-15T00:00:00.000Z') + const config = new Config({ + npmPath: __dirname, + env: { HOME: dir }, + argv: [process.execPath, __filename, `--before=${cliBefore.toISOString()}`], + cwd: dir, + definitions, + shorthands, + flatten, + }) + await t.resolves(config.load(), 'loads without crashing on previously exclusive options') + // CLI is the highest-priority source, so its `before` overrides whatever + // `min-release-age` in the npmrc would have produced. + t.equal( + config.flat.before.toISOString(), + cliBefore.toISOString(), + 'CLI --before overrides npmrc min-release-age' + ) +}) + +// A higher-priority source must be able to relax (or override) a stricter +// lower-priority `min-release-age`. Previously this would have thrown via +// the `exclusive` check; now it follows normal cli > npmrc precedence. +t.test('CLI --min-release-age=0 relaxes a stricter npmrc min-release-age', async t => { + const dir = t.testdir({ + '.npmrc': 'min-release-age=30', + }) + const config = new Config({ + npmPath: __dirname, + env: { HOME: dir }, + argv: [process.execPath, __filename, '--min-release-age=0'], + cwd: dir, + definitions, + shorthands, + flatten, + }) + await config.load() + // min-release-age=0 means "now" — the CLI must win, not the npmrc's 30 days. + const now = Date.now() + t.ok( + Math.abs(config.flat.before.getTime() - now) < 60_000, + 'flat.before resolves to ~now (CLI overrode the stricter npmrc)' + ) +}) + +// Within a single source, an explicit `before` wins over a relative +// `min-release-age` so the resolution is deterministic regardless of the +// argv parser's key-iteration order. +t.test('within a single source, before wins over min-release-age', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [ + process.execPath, + __filename, + '--min-release-age=1', + '--before=2020-01-01T00:00:00.000Z', + ], + cwd: path, + definitions, + shorthands, + flatten, + }) + await config.load() + t.equal( + config.flat.before.toISOString(), + '2020-01-01T00:00:00.000Z', + 'explicit --before wins over --min-release-age in the same source' + ) +})