Skip to content

Commit 3499a2f

Browse files
feat(cooldown): support for cooldown predicate function (#1563)
1 parent 9af070e commit 3499a2f

File tree

10 files changed

+152
-26
lines changed

10 files changed

+152
-26
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ Note for latest/tag targets:
460460

461461
> :warning: For packages that update frequently (e.g. daily releases), using a long cooldown period (7+ days) with the default `--target latest` or `--target @tag` may prevent all updates since new versions will be published before older ones meet the cooldown requirement. Please consider this when setting your cooldown period.
462462
463+
You can also provide a custom function in your .ncurc.js file or when importing npm-check-updates as a module.
464+
465+
> :warning: The predicate function is only available in .ncurc.js or when importing npm-check-updates as a module, not on the command line. To convert a JSON config to a JS config, follow the instructions at https://github.com/raineorshine/npm-check-updates#config-functions.
466+
467+
```js
468+
/** Set cooldown to 3 days but skip it for `@my-company` packages.
469+
@param packageName The name of the dependency.
470+
@returns Cooldown days restriction for given package (when null cooldown will be skipped for given package).
471+
*/
472+
cooldown: packageName => (packageName.startsWith('@my-company') ? null : 3)
473+
```
474+
463475
## doctor
464476

465477
Usage:

src/cli-options.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,18 @@ ${chalk.bold('Note for latest/tag targets')}:
599599
600600
> :warning: For packages that update frequently (e.g. daily releases), using a long cooldown period (7+ days) with the default \`--target latest\` or \`--target @tag\` may prevent all updates since new versions will be published before older ones meet the cooldown requirement. Please consider this when setting your cooldown period.
601601
602+
You can also provide a custom function in your .ncurc.js file or when importing npm-check-updates as a module.
603+
604+
> :warning: The predicate function is only available in .ncurc.js or when importing npm-check-updates as a module, not on the command line. To convert a JSON config to a JS config, follow the instructions at https://github.com/raineorshine/npm-check-updates#config-functions.
605+
606+
${codeBlock(
607+
`${chalk.gray(`/** Set cooldown to 3 days but skip it for \`@my-company\` packages.
608+
@param packageName The name of the dependency.
609+
@returns Cooldown days restriction for given package (when null cooldown will be skipped for given package).
610+
*/`)}
611+
${chalk.green('cooldown')}: (packageName) ${chalk.cyan('=>')} packageName.startsWith(${chalk.yellow("'@my-company'")}) ? ${chalk.cyan('null')} : ${chalk.cyan('3')}`,
612+
{ markdown },
613+
)}
602614
`
603615
}
604616

@@ -971,7 +983,7 @@ const cliOptions: CLIOption[] = [
971983
arg: 'n',
972984
description:
973985
'Sets a minimum age (in days) for package versions to be considered for upgrade, reducing the risk of installing newly published, potentially compromised packages.',
974-
type: 'number',
986+
type: `number | CooldownFunction`,
975987
help: extendedHelpCooldown,
976988
parse: s => parseInt(s, 10),
977989
},

src/lib/initOptions.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,13 @@ async function initOptions(runOptions: RunOptions, { cli }: { cli?: boolean } =
183183
programError(options, `--registry must be a valid URL. Invalid value: "${options.registry}"`)
184184
}
185185

186-
if (options.cooldown != null && (isNaN(options.cooldown) || options.cooldown < 0)) {
187-
programError(options, 'Cooldown must be a non-negative integer representing days since published')
186+
if (options.cooldown != null) {
187+
const isValidNumber = typeof options.cooldown === 'number' && !isNaN(options.cooldown) && options.cooldown >= 0
188+
const isValidFunction = typeof options.cooldown === 'function'
189+
190+
if (!isValidNumber && !isValidFunction) {
191+
programError(options, 'Cooldown must be a non-negative integer representing days since published or a function')
192+
}
188193
}
189194

190195
const target: Target = options.target || 'latest'

src/package-managers/filters.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import semver from 'semver'
22
import * as versionUtil from '../lib/version-util'
3+
import { CooldownFunction } from '../types/CooldownFunction'
34
import { Index } from '../types/IndexType'
45
import { Maybe } from '../types/Maybe'
56
import { Options } from '../types/Options'
@@ -62,17 +63,24 @@ export function satisfiesPeerDependencies(versionResult: Partial<Packument>, pee
6263
* @param cooldownDays - The cooldown period in days. If not specified or invalid, the function returns true.
6364
* @returns `true` if the version's release date is older than the cooldown period, otherwise `false`.
6465
*/
65-
export function satisfiesCooldownPeriod(versionResult: Partial<Packument>, cooldownDays: Maybe<number>): boolean {
66+
export function satisfiesCooldownPeriod(
67+
versionResult: Partial<Packument>,
68+
cooldownDaysOrPredicateFn: Maybe<number> | Maybe<CooldownFunction>,
69+
): boolean {
6670
const version = versionResult.version
6771
const versionTimeData = versionResult?.time?.[version!]
6872

69-
if (!cooldownDays) return true
73+
if (!cooldownDaysOrPredicateFn) return true
7074
if (!versionTimeData) return false
7175

7276
const versionReleaseDate = new Date(versionTimeData)
7377
const DAY_AS_MS = 86400000 // milliseconds in a day
74-
const cooldownDaysAsMs = cooldownDays * DAY_AS_MS
75-
return Date.now() - versionReleaseDate.getTime() >= cooldownDaysAsMs
78+
const cooldownDays =
79+
typeof cooldownDaysOrPredicateFn === 'function'
80+
? (cooldownDaysOrPredicateFn(versionResult.name!) ?? 0) // when null or undefined is returned cooldown is skipped for given package
81+
: cooldownDaysOrPredicateFn
82+
83+
return Date.now() - versionReleaseDate.getTime() >= cooldownDays * DAY_AS_MS
7684
}
7785

7886
/** Returns a composite predicate that filters out deprecated, prerelease, and node engine incompatibilies from version objects returns by packument. */

src/package-managers/npm.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,21 +123,22 @@ const fetchPartialPackument = async (
123123
}
124124

125125
/**
126-
* Decorates a tag-specific/version-specific packument object with the `time` property from the full packument,
126+
* Decorates a tag-specific/version-specific packument object with the package name and `time` property from the full packument,
127127
* if the `time` information for the tag's version exists.
128128
*
129129
* @param tagPackument - A partial packument object representing a specific tag/version.
130130
* @param packument - The full packument object, potentially containing time metadata for versions.
131-
* @returns A new packument object that includes the `time` property if available for the tag's version.
131+
* @returns A new packument object that includes the `time` property if available for the tag's version and package name.
132132
*/
133-
const decorateTagPackumentWithTime = (
133+
const decorateTagPackumentWithTimeAndName = (
134134
tagPackument: Partial<Packument>,
135135
packument: Partial<Packument>,
136136
): Partial<Packument> => {
137137
const version = tagPackument.version
138138

139139
return {
140140
...tagPackument,
141+
name: packument.name,
141142
...(packument?.time?.[version!] ? { time: packument.time } : null),
142143
}
143144
}
@@ -702,7 +703,7 @@ export const greatest: GetVersion = async (
702703
version:
703704
Object.values(versions || {})
704705
.filter(tagPackument =>
705-
filterPredicate(options)(decorateTagPackumentWithTime(tagPackument, packument as Partial<Packument>)),
706+
filterPredicate(options)(decorateTagPackumentWithTimeAndName(tagPackument, packument as Partial<Packument>)),
706707
)
707708
.map(o => o.version)
708709
.sort(versionUtil.compareVersions)
@@ -830,7 +831,7 @@ export const distTag: GetVersion = async (
830831
version,
831832
}
832833

833-
const tagPackumentWithTime = decorateTagPackumentWithTime(tagPackument, packument as Partial<Packument>)
834+
const tagPackumentWithTime = decorateTagPackumentWithTimeAndName(tagPackument, packument as Partial<Packument>)
834835

835836
// latest should not be deprecated
836837
// if latest exists and latest is not a prerelease version, return it
@@ -921,7 +922,7 @@ export const newest: GetVersion = async (
921922
if (options.cooldown) {
922923
const versionsSatisfiesfyingCooldownPeriod = versionsSortedByTime.filter(version =>
923924
satisfiesCooldownPeriod(
924-
decorateTagPackumentWithTime((result as Packument).versions[version], result as Packument),
925+
decorateTagPackumentWithTimeAndName((result as Packument).versions[version], result as Packument),
925926
options.cooldown,
926927
),
927928
)
@@ -967,7 +968,7 @@ export const minor: GetVersion = async (
967968
const version = versionUtil.findGreatestByLevel(
968969
Object.values(versions || {})
969970
.filter(tagPackument =>
970-
filterPredicate(options)(decorateTagPackumentWithTime(tagPackument, packument as Partial<Packument>)),
971+
filterPredicate(options)(decorateTagPackumentWithTimeAndName(tagPackument, packument as Partial<Packument>)),
971972
)
972973
.map(o => o.version),
973974
currentVersion,
@@ -1011,7 +1012,7 @@ export const patch: GetVersion = async (
10111012
const version = versionUtil.findGreatestByLevel(
10121013
Object.values(versions || {})
10131014
.filter(tagPackument =>
1014-
filterPredicate(options)(decorateTagPackumentWithTime(tagPackument, packument as Partial<Packument>)),
1015+
filterPredicate(options)(decorateTagPackumentWithTimeAndName(tagPackument, packument as Partial<Packument>)),
10151016
)
10161017
.map(o => o.version),
10171018
currentVersion,
@@ -1057,7 +1058,7 @@ export const semver: GetVersion = async (
10571058

10581059
const versionsFiltered = Object.values(versions || {})
10591060
.filter(tagPackument =>
1060-
filterPredicate(options)(decorateTagPackumentWithTime(tagPackument, packument as Partial<Packument>)),
1061+
filterPredicate(options)(decorateTagPackumentWithTimeAndName(tagPackument, packument as Partial<Packument>)),
10611062
)
10621063
.map(o => o.version)
10631064
// TODO: Upgrading within a prerelease does not seem to work.

src/scripts/build-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import { FilterResultsFunction } from './FilterResultsFunction'
8888
import { GroupFunction } from './GroupFunction'
8989
import { PackageFile } from './PackageFile'
9090
import { TargetFunction } from './TargetFunction'
91+
import { CooldownFunction } from './CooldownFunction'
9192
9293
/** Options that can be given on the CLI or passed to the ncu module to control all behavior. */
9394
export interface RunOptions {

src/types/CooldownFunction.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** A function that can be provided to the --cooldown option for custom cooldown predicate. */
2+
export type CooldownFunction = (packageName: string) => number | null

src/types/RunOptions.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,16 @@
181181
"type": "string"
182182
},
183183
"cooldown": {
184-
"description": "Sets a minimum age (in days) for package versions to be considered for upgrade, reducing the risk of installing newly published, potentially compromised packages. Run \"ncu --help --cooldown\" for details.",
185-
"type": "number"
184+
"anyOf": [
185+
{
186+
"description": "A function that can be provided to the --cooldown option for custom cooldown predicate.",
187+
"type": "object"
188+
},
189+
{
190+
"type": "number"
191+
}
192+
],
193+
"description": "Sets a minimum age (in days) for package versions to be considered for upgrade, reducing the risk of installing newly published, potentially compromised packages. Run \"ncu --help --cooldown\" for details."
186194
},
187195
"cwd": {
188196
"description": "Working directory in which npm will be executed.",

src/types/RunOptions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** This file is generated automatically from the options specified in /src/cli-options.ts. Do not edit manually. Run "npm run build" or "npm run build:options" to build. */
2+
import { CooldownFunction } from './CooldownFunction'
23
import { FilterFunction } from './FilterFunction'
34
import { FilterResultsFunction } from './FilterResultsFunction'
45
import { GroupFunction } from './GroupFunction'
@@ -41,7 +42,7 @@ export interface RunOptions {
4142
configFilePath?: string
4243

4344
/** Sets a minimum age (in days) for package versions to be considered for upgrade, reducing the risk of installing newly published, potentially compromised packages. Run "ncu --help --cooldown" for details. */
44-
cooldown?: number
45+
cooldown?: number | CooldownFunction
4546

4647
/** Working directory in which npm will be executed. */
4748
cwd?: string

test/cooldown.test.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { expect } from 'chai'
22
import Sinon from 'sinon'
33
import ncu from '../src/'
4-
import { MockedVersions } from '../src/types/MockedVersions'
54
import { PackageFile } from '../src/types/PackageFile'
65
import { Packument } from '../src/types/Packument'
76
import chaiSetup from './helpers/chaiSetup'
@@ -27,13 +26,13 @@ interface CreateMockParams {
2726
* @param params.distTags - An object representing distribution tags for the package.
2827
* @returns An object representing mocked package versions, including name, versions, time, and distTags.
2928
*/
30-
const createMockVersion = ({ name, versions, distTags }: CreateMockParams): MockedVersions => {
29+
const createMockVersion = ({ name, versions, distTags }: CreateMockParams): Partial<Packument> => {
3130
return {
3231
name,
3332
version: Object.keys(versions)[0],
3433
versions: Object.fromEntries(Object.entries(versions).map(([version]) => [version, { version } as Packument])),
3534
time: Object.fromEntries(Object.entries(versions).map(([version, date]) => [version, date])),
36-
distTags,
35+
'dist-tags': distTags,
3736
}
3837
}
3938

@@ -46,20 +45,18 @@ describe('cooldown', () => {
4645
it('throws error for negative cooldown', () => {
4746
expect(
4847
ncu({
49-
packageFile: 'test/test-data/cooldown/package.json',
5048
cooldown: -1,
5149
}),
52-
).to.be.rejectedWith('Cooldown must be a non-negative integer representing days since published')
50+
).to.be.rejectedWith('Cooldown must be a non-negative integer representing days since published or a function')
5351
})
5452

5553
it('throws error for non-numeric cooldown', () => {
5654
expect(
5755
ncu({
58-
packageFile: 'test/test-data/cooldown/package.json',
5956
// @ts-expect-error -- testing invalid input
6057
cooldown: 'invalid',
6158
}),
62-
).to.be.rejectedWith('Cooldown must be a non-negative integer representing days since published')
59+
).to.be.rejectedWith('Cooldown must be a non-negative integer representing days since published or a function')
6360
})
6461
})
6562

@@ -479,4 +476,83 @@ describe('cooldown', () => {
479476

480477
stub.restore()
481478
})
479+
480+
describe('cooldown predicate function', () => {
481+
it('should skip cooldown check when predicate returns null', async () => {
482+
// Given: cooldown set to 10, test-package@1.0.0 installed, latest version 1.1.0 released 5 days ago (within cooldown)
483+
const cooldown = 10
484+
const packageData: PackageFile = {
485+
dependencies: {
486+
'test-package': '1.0.0',
487+
},
488+
}
489+
const stub = stubVersions(
490+
createMockVersion({
491+
name: 'test-package',
492+
versions: {
493+
'1.1.0': new Date(NOW - 5 * DAY).toISOString(),
494+
},
495+
distTags: {
496+
latest: '1.1.0',
497+
},
498+
}),
499+
)
500+
501+
// When: cooldown predicate returns null for test-package
502+
const result = await ncu({
503+
packageData,
504+
cooldown: packageName => (packageName === 'test-package' ? null : cooldown),
505+
target: 'latest',
506+
})
507+
508+
// Then: test-package is upgraded to version 1.1.0 (cooldown check skipped)
509+
expect(result).to.have.property('test-package', '1.1.0')
510+
511+
stub.restore()
512+
})
513+
514+
it('should apply custom cooldown when predicate returns a number', async () => {
515+
// Given: default cooldown set to 10, test-package and test-package-2 - both installed in version 1.0.0, and both has the latest version 1.1.0 released 5 days ago (within cooldown)
516+
const cooldown = 10
517+
const packageData: PackageFile = {
518+
dependencies: {
519+
'test-package': '1.0.0',
520+
'test-package-2': '1.0.0',
521+
},
522+
}
523+
const stub = stubVersions({
524+
'test-package': createMockVersion({
525+
name: 'test-package',
526+
versions: {
527+
'1.1.0': new Date(NOW - 5 * DAY).toISOString(),
528+
},
529+
distTags: {
530+
latest: '1.1.0',
531+
},
532+
}),
533+
'test-package-2': createMockVersion({
534+
name: 'test-package-2',
535+
versions: {
536+
'1.1.0': new Date(NOW - 5 * DAY).toISOString(),
537+
},
538+
distTags: {
539+
latest: '1.1.0',
540+
},
541+
}),
542+
})
543+
544+
// When: cooldown predicate returns 5 for test-package (skipping cooldown), and 10 for the rest packages
545+
const result = await ncu({
546+
packageData,
547+
cooldown: (packageName: string) => (packageName === 'test-package' ? 5 : cooldown),
548+
target: 'latest',
549+
})
550+
551+
// Then: test-package is upgraded to version 1.1.0 (as cooldown for this package was set to 5), but test-package-2 is not upgraded (as rest of the packages use default cooldown of 10)
552+
expect(result).to.have.property('test-package', '1.1.0')
553+
expect(result).to.not.have.property('test-package-2')
554+
555+
stub.restore()
556+
})
557+
})
482558
})

0 commit comments

Comments
 (0)