diff --git a/lib/commands/install.js b/lib/commands/install.js index 5970fddfdfe4f..f4829abea9743 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -31,6 +31,7 @@ class Install extends ArboristWorkspaceCmd { 'audit', 'before', 'min-release-age', + 'min-release-age-exclude', 'bin-links', 'fund', 'dry-run', diff --git a/lib/commands/outdated.js b/lib/commands/outdated.js index e6f2cd006b60f..4c98e7597ad13 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-exclude', ] #tree diff --git a/lib/commands/query.js b/lib/commands/query.js index 5e70e25f32e62..63bd00c3206b3 100644 --- a/lib/commands/query.js +++ b/lib/commands/query.js @@ -49,6 +49,9 @@ class Query extends BaseCommand { 'include-workspace-root', 'package-lock-only', 'expect-results', + 'before', + 'min-release-age', + 'min-release-age-exclude', ] constructor (...args) { diff --git a/node_modules/npm-pick-manifest/lib/index.js b/node_modules/npm-pick-manifest/lib/index.js index 985c78df7a9bf..0f950f0731b2b 100644 --- a/node_modules/npm-pick-manifest/lib/index.js +++ b/node_modules/npm-pick-manifest/lib/index.js @@ -2,6 +2,7 @@ const npa = require('npm-package-arg') const semver = require('semver') +const { minimatch } = require('minimatch') const { checkEngine } = require('npm-install-checks') const normalizeBin = require('npm-normalize-package-bin') @@ -21,6 +22,16 @@ const avoidSemverOpt = { includePrerelease: true, loose: true } const shouldAvoid = (ver, avoid) => avoid && semver.satisfies(ver, avoid, avoidSemverOpt) +const isExcludedFromTimeFilter = (name, exclude) => { + if (!exclude) { + return false + } + const patterns = Array.isArray(exclude) ? exclude : [exclude] + return patterns + .filter(pattern => typeof pattern === 'string' && pattern) + .some(pattern => minimatch(name, pattern)) +} + const decorateAvoid = (result, avoid) => result && shouldAvoid(result.version, avoid) ? { ...result, _shouldAvoid: true } @@ -35,6 +46,7 @@ const pickManifest = (packument, wanted, opts) => { includeStaged = false, avoid = null, avoidStrict = false, + minReleaseAgeExclude = opts['min-release-age-exclude'], } = opts const { name, time: verTimes } = packument @@ -84,7 +96,9 @@ const pickManifest = (packument, wanted, opts) => { const restricted = (packument.policyRestrictions && packument.policyRestrictions.versions) || {} - const time = before && verTimes ? +(new Date(before)) : Infinity + const time = before && verTimes && !isExcludedFromTimeFilter(name, minReleaseAgeExclude) + ? +(new Date(before)) + : Infinity const spec = npa.resolve(name, wanted || defaultTag) const type = spec.type const distTags = packument['dist-tags'] || {} @@ -217,3 +231,5 @@ module.exports = (packument, wanted, opts = {}) => { defaultTag, }) } + +module.exports.isExcludedFromTimeFilter = isExcludedFromTimeFilter diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 2f8891f886263..f40fbd5bc921b 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -1151,6 +1151,19 @@ This config cannot be used with: \`before\` This value is not exported to the environment for child processes. +#### \`min-release-age-exclude\` + +* Default: +* Type: String (can be set multiple times) + +Exclude package names from \`min-release-age\` and \`before\` publish-time +filtering. + +Values can be exact package names (\`left-pad\`) or glob patterns +(\`@myorg/*\`). + +This value is not exported to the environment for child processes. + #### \`name\` * Default: null @@ -2350,6 +2363,7 @@ Array [ "maxsockets", "message", "min-release-age", + "min-release-age-exclude", "node-gyp", "node-options", "noproxy", @@ -2508,6 +2522,7 @@ Array [ "maxsockets", "message", "min-release-age", + "min-release-age-exclude", "node-gyp", "noproxy", "offline", @@ -2675,6 +2690,7 @@ Object { "logColor": false, "maxSockets": 15, "message": "%s", + "minReleaseAgeExclude": Array [], "name": null, "nodeBin": "{NODE}", "nodeGyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js", @@ -3948,8 +3964,10 @@ Options: [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] [--foreground-scripts] [--ignore-scripts] [--allow-git ] -[--no-audit] [--before |--min-release-age ] [--no-bin-links] -[--no-fund] [--dry-run] [--cpu ] [--os ] [--libc ] +[--no-audit] [--before |--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4007,6 +4025,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + Exclude package names from \`min-release-age\` and \`before\` publish-time + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package @@ -4066,6 +4087,7 @@ aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall #### \`audit\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` #### \`bin-links\` #### \`fund\` #### \`dry-run\` @@ -4189,8 +4211,10 @@ Options: [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] [--foreground-scripts] [--ignore-scripts] [--allow-git ] -[--no-audit] [--before |--min-release-age ] [--no-bin-links] -[--no-fund] [--dry-run] [--cpu ] [--os ] [--libc ] +[--no-audit] [--before |--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4248,6 +4272,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + Exclude package names from \`min-release-age\` and \`before\` publish-time + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package @@ -4307,6 +4334,7 @@ alias: it #### \`audit\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` #### \`bin-links\` #### \`fund\` #### \`dry-run\` @@ -4739,6 +4767,7 @@ Options: [-a|--all] [--json] [-l|--long] [-p|--parseable] [-g|--global] [-w|--workspace [-w|--workspace ...]] [--before |--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] -a|--all When running \`npm outdated\` and \`npm ls\`, setting \`--all\` will show @@ -4761,6 +4790,9 @@ Options: --before If passed to \`npm install\`, will rebuild the npm tree such that only + --min-release-age-exclude + Exclude package names from \`min-release-age\` and \`before\` publish-time + Run "npm help outdated" for more info @@ -4776,6 +4808,7 @@ npm outdated [ ...] #### \`workspace\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` ` exports[`test/lib/docs.js TAP usage owner > must match snapshot 1`] = ` @@ -5132,6 +5165,8 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--package-lock-only] [--expect-results|--expect-result-count ] +[--before |--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] -g|--global Operates in "global" mode, so that packages are installed into the @@ -5151,6 +5186,15 @@ Options: --expect-results Tells npm whether or not to expect results from the command. + --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 + + --min-release-age-exclude + Exclude package names from \`min-release-age\` and \`before\` publish-time + Run "npm help query" for more info @@ -5165,6 +5209,9 @@ npm query #### \`package-lock-only\` #### \`expect-results\` #### \`expect-result-count\` +#### \`before\` +#### \`min-release-age\` +#### \`min-release-age-exclude\` ` exports[`test/lib/docs.js TAP usage rebuild > must match snapshot 1`] = ` diff --git a/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index 626af0c908e9c..9b13abfb58fe8 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -6,6 +6,7 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const { log } = require('proc-log') const { minimatch } = require('minimatch') const npa = require('npm-package-arg') +const { isExcludedFromTimeFilter } = require('npm-pick-manifest') const pacote = require('pacote') const semver = require('semver') const npmFetch = require('npm-registry-fetch') @@ -873,6 +874,7 @@ const combinators = { // get a list of available versions of a package filtered to respect --before // NOTE: this runs over each node and should not throw const getPackageVersions = async (name, opts) => { + const minReleaseAgeExclude = opts.minReleaseAgeExclude let packument try { packument = await pacote.packument(name, { @@ -890,7 +892,7 @@ const getPackageVersions = async (name, opts) => { // if the packument has a time property, and the user passed a before flag, then // we filter this list down to only those versions that existed before the specified date - if (packument.time && opts.before) { + if (packument.time && opts.before && !isExcludedFromTimeFilter(name, minReleaseAgeExclude)) { candidates = candidates.filter((version) => { // this version isn't found in the times at all, drop it if (!packument.time[version]) { diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 70f14c302c1c6..4b55f18c41845 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -393,6 +393,9 @@ t.test('query-selector-all', async t => { : options.before title += ` before ${friendlyTime}` } + if (options.minReleaseAgeExclude) { + title += ` exclude ${JSON.stringify(options.minReleaseAgeExclude)}` + } t.test(title, async t => { const res = await querySelectorAll(tree, selector, options) t.same( @@ -865,6 +868,20 @@ t.test('query-selector-all', async t => { [':outdated(out-of-range)', [ 'dash-separated-pkg@1.0.0', // 2.0.0 is available, out-of-range and published yesterday ], { before: yesterday }], + [':outdated(out-of-range)', [ + 'dash-separated-pkg@1.0.0', // 2.0.0 is available, out-of-range and published yesterday + 'bar@1.4.0', // excluded from --before filter + ], { + before: yesterday, + minReleaseAgeExclude: ['bar'], + }], + [':outdated(out-of-range)', [ + 'dash-separated-pkg@1.0.0', // 2.0.0 is available, out-of-range and published yesterday + 'bar@1.4.0', // excluded from --before filter by glob + ], { + before: yesterday, + minReleaseAgeExclude: ['ba*'], + }], [':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever // vuln pseudo @@ -1123,3 +1140,24 @@ t.test('linked strategy: :root > * excludes transitive deps and store nodes', as 'nopt@7.2.1', ], ':root * should return all descendants including transitive deps') }) + +t.test('isExcludedFromTimeFilter with scoped package patterns', async t => { + const { isExcludedFromTimeFilter } = require('npm-pick-manifest') + + t.equal(isExcludedFromTimeFilter('@myorg/foo', ['@myorg/*']), true, + 'scoped glob @myorg/* matches @myorg/foo') + t.equal(isExcludedFromTimeFilter('@myorg/bar', ['@myorg/*']), true, + 'scoped glob @myorg/* matches @myorg/bar') + t.equal(isExcludedFromTimeFilter('@other/foo', ['@myorg/*']), false, + 'scoped glob @myorg/* does not match @other/foo') + t.equal(isExcludedFromTimeFilter('unscoped', ['@myorg/*']), false, + 'scoped glob @myorg/* does not match unscoped package') + t.equal(isExcludedFromTimeFilter('@myorg/foo', ['@myorg/foo']), true, + 'exact scoped name matches') + t.equal(isExcludedFromTimeFilter('@myorg/foo', ['@myorg/bar']), false, + 'exact scoped name does not match different package') + t.equal(isExcludedFromTimeFilter('@myorg/foo', []), false, + 'empty exclude list matches nothing') + t.equal(isExcludedFromTimeFilter('@myorg/foo', undefined), false, + 'undefined exclude list matches nothing') +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 7c5b2ce170d89..f19eac37ff40f 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1382,6 +1382,25 @@ const definitions = { } }, }), + 'min-release-age-exclude': new Definition('min-release-age-exclude', { + default: [], + hint: '', + type: [String, Array], + envExport: false, + description: ` + Exclude package names from \`min-release-age\` and \`before\` publish-time + filtering. + + Values can be exact package names (\`left-pad\`) or glob patterns + (\`@myorg/*\`). + `, + flatten: (key, obj, flatOptions) => { + const patterns = Array.isArray(obj[key]) + ? obj[key] + : obj[key] ? [obj[key]] : [] + flatOptions.minReleaseAgeExclude = patterns.filter(Boolean) + }, + }), 'node-gyp': new Definition('node-gyp', { default: (() => { try { diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index fea502d38f767..d685537cce1d9 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1868,3 +1868,25 @@ t.test('before and min-release-age', async t => { // Simple gut check to make sure we didn't do + instead of - t.ok(config.flat.before < Date.now(), 'before date is in the past not the future') }) + +t.test('min-release-age-exclude flattens to list', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [ + process.execPath, + __filename, + '--min-release-age-exclude', + '@myorg/*', + '--min-release-age-exclude', + 'left-pad', + ], + cwd: path, + definitions, + shorthands, + flatten, + }) + await config.load() + t.same(config.flat.minReleaseAgeExclude, ['@myorg/*', 'left-pad']) +})