mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-10 18:18:56 -04:00
feat: respect minimumReleaseAge in outdated command (#10030)
close #10009 * feat: respect minimumReleaseAge in outdated command * chore: add changeset * fix: remove unnecessary 'latest' to '*' conversion in outdated command * refactor: move publishedBy and matcher creation outside getManifest * refactor: outdated * docs: update changeset --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
7
.changeset/dry-impalas-mate.md
Normal file
7
.changeset/dry-impalas-mate.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@pnpm/plugin-commands-outdated": patch
|
||||
"@pnpm/outdated": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Outdated command respects `minimumReleaseAge` configuration [#10030](https://github.com/pnpm/pnpm/pull/10030).
|
||||
@@ -3,12 +3,15 @@ import {
|
||||
createResolver,
|
||||
type ResolveFunction,
|
||||
} from '@pnpm/client'
|
||||
import { createMatcher } from '@pnpm/matcher'
|
||||
import { type DependencyManifest } from '@pnpm/types'
|
||||
|
||||
interface GetManifestOpts {
|
||||
dir: string
|
||||
lockfileDir: string
|
||||
rawConfig: object
|
||||
minimumReleaseAge?: number
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
}
|
||||
|
||||
export type ManifestGetterOptions = Omit<ClientOptions, 'authConfig'>
|
||||
@@ -18,20 +21,54 @@ export type ManifestGetterOptions = Omit<ClientOptions, 'authConfig'>
|
||||
export function createManifestGetter (
|
||||
opts: ManifestGetterOptions
|
||||
): (packageName: string, bareSpecifier: string) => Promise<DependencyManifest | null> {
|
||||
const { resolve } = createResolver({ ...opts, authConfig: opts.rawConfig })
|
||||
return getManifest.bind(null, resolve, opts)
|
||||
const { resolve } = createResolver({
|
||||
...opts,
|
||||
authConfig: opts.rawConfig,
|
||||
filterMetadata: Boolean(opts.minimumReleaseAge),
|
||||
strictPublishedByCheck: Boolean(opts.minimumReleaseAge),
|
||||
})
|
||||
|
||||
const publishedBy = opts.minimumReleaseAge
|
||||
? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000)
|
||||
: undefined
|
||||
|
||||
const isExcludedMatcher = opts.minimumReleaseAgeExclude
|
||||
? createMatcher(opts.minimumReleaseAgeExclude)
|
||||
: undefined
|
||||
|
||||
return getManifest.bind(null, {
|
||||
...opts,
|
||||
resolve,
|
||||
publishedBy,
|
||||
isExcludedMatcher,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getManifest (
|
||||
resolve: ResolveFunction,
|
||||
opts: GetManifestOpts,
|
||||
opts: GetManifestOpts & {
|
||||
resolve: ResolveFunction
|
||||
publishedBy?: Date
|
||||
isExcludedMatcher?: ((packageName: string) => boolean)
|
||||
},
|
||||
packageName: string,
|
||||
bareSpecifier: string
|
||||
): Promise<DependencyManifest | null> {
|
||||
const resolution = await resolve({ alias: packageName, bareSpecifier }, {
|
||||
lockfileDir: opts.lockfileDir,
|
||||
preferredVersions: {},
|
||||
projectDir: opts.dir,
|
||||
})
|
||||
return resolution?.manifest ?? null
|
||||
const isExcluded = opts.isExcludedMatcher?.(packageName)
|
||||
const effectivePublishedBy = isExcluded ? undefined : opts.publishedBy
|
||||
|
||||
try {
|
||||
const resolution = await opts.resolve({ alias: packageName, bareSpecifier }, {
|
||||
lockfileDir: opts.lockfileDir,
|
||||
preferredVersions: {},
|
||||
projectDir: opts.dir,
|
||||
publishedBy: effectivePublishedBy,
|
||||
})
|
||||
return resolution?.manifest ?? null
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'ERR_PNPM_NO_MATCHING_VERSION' && effectivePublishedBy) {
|
||||
// No versions found that meet the minimumReleaseAge requirement
|
||||
return null
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export async function outdated (
|
||||
lockfileDir: string
|
||||
manifest: ProjectManifest
|
||||
match?: (dependencyName: string) => boolean
|
||||
minimumReleaseAge?: number
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
prefix: string
|
||||
registries: Registries
|
||||
wantedLockfile: LockfileObject | null
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function outdatedDepsOfProjects (
|
||||
compatible?: boolean
|
||||
ignoreDependencies?: string[]
|
||||
include: IncludedDependencies
|
||||
minimumReleaseAge?: number
|
||||
minimumReleaseAgeExclude?: string[]
|
||||
} & Partial<Pick<ManifestGetterOptions, 'fullMetadata' | 'lockfileDir'>>
|
||||
): Promise<OutdatedPackage[][]> {
|
||||
if (!opts.lockfileDir) {
|
||||
@@ -37,8 +39,10 @@ export async function outdatedDepsOfProjects (
|
||||
const wantedLockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }) ?? currentLockfile
|
||||
const getLatestManifest = createManifestGetter({
|
||||
...opts,
|
||||
fullMetadata: opts.fullMetadata === true,
|
||||
fullMetadata: opts.fullMetadata === true || Boolean(opts.minimumReleaseAge),
|
||||
lockfileDir,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
})
|
||||
return Promise.all(pkgs.map(async ({ rootDir, manifest }): Promise<OutdatedPackage[]> => {
|
||||
const match = (args.length > 0) && createMatcher(args) || undefined
|
||||
@@ -52,6 +56,8 @@ export async function outdatedDepsOfProjects (
|
||||
lockfileDir,
|
||||
manifest,
|
||||
match,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
prefix: rootDir,
|
||||
registries: opts.registries,
|
||||
wantedLockfile,
|
||||
|
||||
@@ -22,7 +22,7 @@ test('getManifest()', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(await getManifest(resolve, opts, 'foo', 'latest')).toStrictEqual({
|
||||
expect(await getManifest({ ...opts, resolve }, 'foo', 'latest')).toStrictEqual({
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
})
|
||||
@@ -40,8 +40,111 @@ test('getManifest()', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(await getManifest(resolve2, opts, '@scope/foo', 'latest')).toStrictEqual({
|
||||
expect(await getManifest({ ...opts, resolve: resolve2 }, '@scope/foo', 'latest')).toStrictEqual({
|
||||
name: 'foo',
|
||||
version: '2.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
test('getManifest() with minimumReleaseAge filters latest when too new', async () => {
|
||||
const opts = {
|
||||
dir: '',
|
||||
lockfileDir: '',
|
||||
rawConfig: {},
|
||||
minimumReleaseAge: 10080,
|
||||
}
|
||||
|
||||
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
|
||||
|
||||
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
|
||||
expect(wantedPackage.bareSpecifier).toBe('latest')
|
||||
expect(resolveOpts.publishedBy).toBeInstanceOf(Date)
|
||||
|
||||
// Simulate latest version being too new
|
||||
const error = new Error('No matching version found') as Error & { code?: string }
|
||||
error.code = 'ERR_PNPM_NO_MATCHING_VERSION'
|
||||
throw error
|
||||
})
|
||||
|
||||
const result = await getManifest({ ...opts, resolve, publishedBy }, 'foo', 'latest')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('getManifest() does not convert non-latest specifiers', async () => {
|
||||
const opts = {
|
||||
dir: '',
|
||||
lockfileDir: '',
|
||||
rawConfig: {},
|
||||
}
|
||||
|
||||
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
|
||||
expect(wantedPackage.bareSpecifier).toBe('^1.0.0')
|
||||
|
||||
return {
|
||||
id: 'foo/1.5.0' as PkgResolutionId,
|
||||
latest: '2.0.0',
|
||||
manifest: {
|
||||
name: 'foo',
|
||||
version: '1.5.0',
|
||||
},
|
||||
resolution: {} as TarballResolution,
|
||||
resolvedVia: 'npm-registry',
|
||||
}
|
||||
})
|
||||
|
||||
await getManifest({ ...opts, resolve }, 'foo', '^1.0.0')
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('getManifest() handles NO_MATCHING_VERSION error gracefully', async () => {
|
||||
const opts = {
|
||||
dir: '',
|
||||
lockfileDir: '',
|
||||
rawConfig: {},
|
||||
}
|
||||
|
||||
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
|
||||
|
||||
const resolve: ResolveFunction = jest.fn(async function () {
|
||||
const error = new Error('No matching version found') as Error & { code?: string }
|
||||
error.code = 'ERR_PNPM_NO_MATCHING_VERSION'
|
||||
throw error
|
||||
})
|
||||
|
||||
const result = await getManifest({ ...opts, resolve, publishedBy }, 'foo', 'latest')
|
||||
|
||||
// Should return null when no version matches minimumReleaseAge
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('getManifest() with minimumReleaseAgeExclude', async () => {
|
||||
const opts = {
|
||||
dir: '',
|
||||
lockfileDir: '',
|
||||
rawConfig: {},
|
||||
}
|
||||
|
||||
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
|
||||
const isExcludedMatcher = (packageName: string) => packageName === 'excluded-package'
|
||||
|
||||
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
|
||||
// Excluded package should not have publishedBy set
|
||||
expect(resolveOpts.publishedBy).toBeUndefined()
|
||||
|
||||
return {
|
||||
id: 'excluded-package/2.0.0' as PkgResolutionId,
|
||||
latest: '2.0.0',
|
||||
manifest: {
|
||||
name: 'excluded-package',
|
||||
version: '2.0.0',
|
||||
},
|
||||
resolution: {} as TarballResolution,
|
||||
resolvedVia: 'npm-registry',
|
||||
}
|
||||
})
|
||||
|
||||
await getManifest({ ...opts, resolve, isExcludedMatcher, publishedBy }, 'excluded-package', 'latest')
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -240,6 +240,239 @@ test('outdated() should return deprecated package even if its current version is
|
||||
])
|
||||
})
|
||||
|
||||
test('outdated() with minimumReleaseAge', async () => {
|
||||
const getLatestManifestForMinimumAge = async (packageName: string) => {
|
||||
// Simulate packages where 'is-negative' 2.1.0 is filtered out due to minimumReleaseAge
|
||||
// and returns 2.0.0 instead
|
||||
return ({
|
||||
'is-negative': {
|
||||
name: 'is-negative',
|
||||
version: '2.0.0', // older version within the age limit
|
||||
},
|
||||
'is-positive': {
|
||||
name: 'is-positive',
|
||||
version: '3.1.0',
|
||||
},
|
||||
})[packageName] ?? null
|
||||
}
|
||||
|
||||
const outdatedPkgs = await outdated({
|
||||
currentLockfile: {
|
||||
importers: {
|
||||
['.' as ProjectId]: {
|
||||
devDependencies: {
|
||||
'is-negative': '1.0.0',
|
||||
'is-positive': '1.0.0',
|
||||
},
|
||||
specifiers: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
lockfileVersion: LOCKFILE_VERSION,
|
||||
packages: {
|
||||
['is-negative@1.0.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-xxx',
|
||||
},
|
||||
},
|
||||
['is-positive@1.0.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-yyy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getLatestManifest: getLatestManifestForMinimumAge,
|
||||
lockfileDir: 'project',
|
||||
manifest: {
|
||||
name: 'with-min-age',
|
||||
version: '1.0.0',
|
||||
devDependencies: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
prefix: 'project',
|
||||
wantedLockfile: {
|
||||
importers: {
|
||||
['.' as ProjectId]: {
|
||||
devDependencies: {
|
||||
'is-negative': '2.1.0',
|
||||
'is-positive': '3.1.0',
|
||||
},
|
||||
specifiers: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
lockfileVersion: LOCKFILE_VERSION,
|
||||
packages: {
|
||||
['is-negative@2.1.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-xxx',
|
||||
},
|
||||
},
|
||||
['is-positive@3.1.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-zzz',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
registries: {
|
||||
default: 'https://registry.npmjs.org/',
|
||||
},
|
||||
minimumReleaseAge: 10080,
|
||||
})
|
||||
|
||||
expect(outdatedPkgs).toStrictEqual([
|
||||
{
|
||||
alias: 'is-negative',
|
||||
belongsTo: 'devDependencies',
|
||||
current: '1.0.0',
|
||||
latestManifest: {
|
||||
name: 'is-negative',
|
||||
version: '2.0.0', // older version returned due to minimumReleaseAge
|
||||
},
|
||||
packageName: 'is-negative',
|
||||
wanted: '2.1.0',
|
||||
workspace: 'with-min-age',
|
||||
},
|
||||
{
|
||||
alias: 'is-positive',
|
||||
belongsTo: 'devDependencies',
|
||||
current: '1.0.0',
|
||||
latestManifest: {
|
||||
name: 'is-positive',
|
||||
version: '3.1.0',
|
||||
},
|
||||
packageName: 'is-positive',
|
||||
wanted: '3.1.0',
|
||||
workspace: 'with-min-age',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('outdated() with minimumReleaseAgeExclude', async () => {
|
||||
const getLatestManifestWithExclude = async (packageName: string) => {
|
||||
// Simulate that 'is-negative' is excluded from minimumReleaseAge
|
||||
// so it returns the real latest version
|
||||
return ({
|
||||
'is-negative': {
|
||||
name: 'is-negative',
|
||||
version: '2.1.0', // latest version (excluded from age filter)
|
||||
},
|
||||
'is-positive': {
|
||||
name: 'is-positive',
|
||||
version: '3.0.0', // older version (age filter applied)
|
||||
},
|
||||
})[packageName] ?? null
|
||||
}
|
||||
|
||||
const outdatedPkgs = await outdated({
|
||||
currentLockfile: {
|
||||
importers: {
|
||||
['.' as ProjectId]: {
|
||||
devDependencies: {
|
||||
'is-negative': '1.0.0',
|
||||
'is-positive': '1.0.0',
|
||||
},
|
||||
specifiers: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
lockfileVersion: LOCKFILE_VERSION,
|
||||
packages: {
|
||||
['is-negative@1.0.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-xxx',
|
||||
},
|
||||
},
|
||||
['is-positive@1.0.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-yyy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getLatestManifest: getLatestManifestWithExclude,
|
||||
lockfileDir: 'project',
|
||||
manifest: {
|
||||
name: 'with-exclude',
|
||||
version: '1.0.0',
|
||||
devDependencies: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
prefix: 'project',
|
||||
wantedLockfile: {
|
||||
importers: {
|
||||
['.' as ProjectId]: {
|
||||
devDependencies: {
|
||||
'is-negative': '2.1.0',
|
||||
'is-positive': '3.1.0',
|
||||
},
|
||||
specifiers: {
|
||||
'is-negative': '^2.1.0',
|
||||
'is-positive': '^1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
lockfileVersion: LOCKFILE_VERSION,
|
||||
packages: {
|
||||
['is-negative@2.1.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-xxx',
|
||||
},
|
||||
},
|
||||
['is-positive@3.1.0' as DepPath]: {
|
||||
resolution: {
|
||||
integrity: 'sha512-zzz',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
registries: {
|
||||
default: 'https://registry.npmjs.org/',
|
||||
},
|
||||
minimumReleaseAge: 10080,
|
||||
minimumReleaseAgeExclude: ['is-negative'],
|
||||
})
|
||||
|
||||
expect(outdatedPkgs).toStrictEqual([
|
||||
{
|
||||
alias: 'is-negative',
|
||||
belongsTo: 'devDependencies',
|
||||
current: '1.0.0',
|
||||
latestManifest: {
|
||||
name: 'is-negative',
|
||||
version: '2.1.0', // latest version (excluded from age filter)
|
||||
},
|
||||
packageName: 'is-negative',
|
||||
wanted: '2.1.0',
|
||||
workspace: 'with-exclude',
|
||||
},
|
||||
{
|
||||
alias: 'is-positive',
|
||||
belongsTo: 'devDependencies',
|
||||
current: '1.0.0',
|
||||
latestManifest: {
|
||||
name: 'is-positive',
|
||||
version: '3.0.0', // older version (age filter applied)
|
||||
},
|
||||
packageName: 'is-positive',
|
||||
wanted: '3.1.0',
|
||||
workspace: 'with-exclude',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('using a matcher', async () => {
|
||||
const outdatedPkgs = await outdated({
|
||||
currentLockfile: {
|
||||
|
||||
@@ -156,6 +156,8 @@ export type OutdatedCommandOptions = {
|
||||
| 'key'
|
||||
| 'localAddress'
|
||||
| 'lockfileDir'
|
||||
| 'minimumReleaseAge'
|
||||
| 'minimumReleaseAgeExclude'
|
||||
| 'networkConcurrency'
|
||||
| 'noProxy'
|
||||
| 'offline'
|
||||
@@ -195,6 +197,8 @@ export async function handler (
|
||||
fullMetadata: opts.long,
|
||||
ignoreDependencies: opts.updateConfig?.ignoreDependencies,
|
||||
include,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
retry: {
|
||||
factor: opts.fetchRetryFactor,
|
||||
maxTimeout: opts.fetchRetryMaxtimeout,
|
||||
|
||||
@@ -57,6 +57,8 @@ export async function outdatedRecursive (
|
||||
...opts,
|
||||
fullMetadata: opts.long,
|
||||
ignoreDependencies: opts.updateConfig?.ignoreDependencies,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
|
||||
retry: {
|
||||
factor: opts.fetchRetryFactor,
|
||||
maxTimeout: opts.fetchRetryMaxtimeout,
|
||||
|
||||
Reference in New Issue
Block a user