diff --git a/packages/angular/cli/src/commands/update/update-resolver.ts b/packages/angular/cli/src/commands/update/update-resolver.ts index 62c02eac6727..90cd04043201 100644 --- a/packages/angular/cli/src/commands/update/update-resolver.ts +++ b/packages/angular/cli/src/commands/update/update-resolver.ts @@ -24,6 +24,7 @@ export class RegistryClient { constructor( private packageManager: PackageManager, private logger: logging.LoggerApi, + readonly minReleaseAge: number = 0, ) {} async getMetadata(packageName: string): Promise { @@ -54,19 +55,46 @@ export class RegistryClient { } } +function isReleaseAgeSatisfied( + registryClient: RegistryClient, + metadata: PackageMetadata, + version: string, +): boolean { + const minReleaseAge = registryClient.minReleaseAge; + if (!minReleaseAge || !metadata.time) { + return true; + } + + const publishTimeStr = metadata.time[version]; + if (!publishTimeStr) { + return true; + } + + const publishTime = Date.parse(publishTimeStr); + if (isNaN(publishTime)) { + return true; + } + + return Date.now() - publishTime >= minReleaseAge; +} + export async function getSatisfyingVersion( registryClient: RegistryClient, - packageName: string, - versions: string[], + metadata: PackageMetadata, range: string, next?: boolean, ): Promise { const options = { includePrerelease: next || undefined }; - const candidates = versions.filter((v) => semver.satisfies(v, range, options)); + let candidates = metadata.versions.filter((v) => semver.satisfies(v, range, options)); + + candidates = candidates.filter((version) => + isReleaseAgeSatisfied(registryClient, metadata, version), + ); + const sorted = semver.rsort(candidates); for (const version of sorted) { - const manifest = await registryClient.getManifest(packageName, version); + const manifest = await registryClient.getManifest(metadata.name, version); if (manifest && !manifest.deprecated) { return version; } @@ -74,7 +102,7 @@ export async function getSatisfyingVersion( // Fallback to deprecated versions if no non-deprecated version satisfies for (const version of sorted) { - const manifest = await registryClient.getManifest(packageName, version); + const manifest = await registryClient.getManifest(metadata.name, version); if (manifest) { return version; } @@ -440,8 +468,7 @@ async function _buildPackageInfo( if (!installedVersion) { installedVersion = (await getSatisfyingVersion( registryClient, - name, - npmPackageJson.versions, + npmPackageJson, packageJsonRange, )) as VersionRange | undefined; } @@ -463,16 +490,23 @@ async function _buildPackageInfo( let targetVersion: VersionRange | undefined = packages.get(name); if (targetVersion) { const distTags = npmPackageJson['dist-tags'] ?? {}; - if (distTags[targetVersion]) { - targetVersion = distTags[targetVersion] as VersionRange; - } else if (targetVersion == 'next') { - targetVersion = distTags['latest'] as VersionRange; + let resolvedVersion: string | undefined = + distTags[targetVersion] ?? (targetVersion === 'next' ? distTags['latest'] : undefined); + + if ( + resolvedVersion && + !isReleaseAgeSatisfied(registryClient, npmPackageJson, resolvedVersion) + ) { + resolvedVersion = undefined; + } + + if (resolvedVersion) { + targetVersion = resolvedVersion as VersionRange; } else { targetVersion = (await getSatisfyingVersion( registryClient, - name, - npmPackageJson.versions, - targetVersion, + npmPackageJson, + distTags[targetVersion] || targetVersion === 'next' ? '*' : targetVersion, )) as VersionRange | undefined; } } @@ -554,14 +588,23 @@ async function resolvePackageVersion( next = false, ): Promise { const distTags = metadata['dist-tags'] ?? {}; - if (distTags[range]) { - return distTags[range]; + let resolvedVersion: string | undefined = + distTags[range] ?? (range === 'next' ? distTags['latest'] : undefined); + + if (resolvedVersion && !isReleaseAgeSatisfied(registryClient, metadata, resolvedVersion)) { + resolvedVersion = undefined; } - if (range === 'next') { - return distTags['latest'] ?? null; + + if (resolvedVersion) { + return resolvedVersion; } - return getSatisfyingVersion(registryClient, metadata.name, metadata.versions, range, next); + return getSatisfyingVersion( + registryClient, + metadata, + distTags[range] || range === 'next' ? '*' : range, + next, + ); } async function _addPackageGroup( @@ -578,17 +621,21 @@ async function _addPackageGroup( const distTags = metadata['dist-tags'] ?? {}; let version = maybePackage; - if (distTags[version]) { - version = distTags[version] as VersionRange; - } else if (version === 'next') { - version = distTags['latest'] as VersionRange; + let resolvedVersion: string | undefined = + distTags[version] ?? (version === 'next' ? distTags['latest'] : undefined); + + if (resolvedVersion && !isReleaseAgeSatisfied(registryClient, metadata, resolvedVersion)) { + resolvedVersion = undefined; + } + + if (resolvedVersion) { + version = resolvedVersion as VersionRange; } else { version = ((await getSatisfyingVersion( registryClient, - metadata.name, - metadata.versions, - version, + metadata, + distTags[version] || version === 'next' ? '*' : version, )) as VersionRange | null) ?? version; } @@ -679,8 +726,7 @@ async function _addPeerDependencies( if (peerMetadata) { const resolvedInstalledVersion = await getSatisfyingVersion( registryClient, - peer, - peerMetadata.versions, + peerMetadata, packageJsonRange, ); @@ -765,7 +811,8 @@ export async function resolveUserUpdatePlan( const usingYarn = options.packageManager === 'yarn'; const packages = _buildPackageList(options, npmDeps, logger); - const registryClient = new RegistryClient(packageManager, logger); + const minReleaseAge = await packageManager.getMinimumReleaseAge(); + const registryClient = new RegistryClient(packageManager, logger, minReleaseAge); const getOrFetchPackageMetadata = async ( packageName: string, diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts index ae410765bd4b..a309830156cf 100644 --- a/packages/angular/cli/src/commands/update/update-resolver_spec.ts +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -11,7 +11,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; -import type { PackageManager, PackageManifest } from '../../package-managers'; +import type { PackageManager, PackageManifest, PackageMetadata } from '../../package-managers'; import { RegistryClient, UpdateResolverOptions, @@ -53,7 +53,7 @@ describe('UpdateResolver', () => { const MOCK_REGISTRY: Record< string, { - metadata: { name: string; 'dist-tags': Record; versions: string[] }; + metadata: PackageMetadata; manifests: Record; } > = { @@ -155,9 +155,26 @@ describe('UpdateResolver', () => { '0.8.26': { name: 'zone.js', version: '0.8.26' }, }, }, + '@angular-devkit-tests/update-release-age': { + metadata: { + name: '@angular-devkit-tests/update-release-age', + 'dist-tags': { latest: '1.2.0' }, + versions: ['1.0.0', '1.1.0', '1.2.0'], + time: { + '1.0.0': '2026-06-01T00:00:00.000Z', + '1.1.0': new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + '1.2.0': new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + }, + }, + manifests: { + '1.0.0': { name: '@angular-devkit-tests/update-release-age', version: '1.0.0' }, + '1.1.0': { name: '@angular-devkit-tests/update-release-age', version: '1.1.0' }, + '1.2.0': { name: '@angular-devkit-tests/update-release-age', version: '1.2.0' }, + }, + }, }; - async function resolvePlan(options: UpdateResolverOptions) { + async function resolvePlan(options: UpdateResolverOptions, minReleaseAge = 0) { const mockPackageManager = { name: 'npm', async getRegistryMetadata(packageName: string) { @@ -166,6 +183,9 @@ describe('UpdateResolver', () => { async getRegistryManifest(packageName: string, version: string) { return MOCK_REGISTRY[packageName]?.manifests[version] ?? null; }, + async getMinimumReleaseAge() { + return minReleaseAge; + }, } as unknown as PackageManager; return resolveUserUpdatePlan(options, mockPackageManager, logger); @@ -333,6 +353,44 @@ describe('UpdateResolver', () => { const result = readFileSync(path.join(tempRoot, 'package.json'), 'utf8'); expect(result.endsWith('}')).toBeTrue(); }); + + it('respects minimumReleaseAge and filters out versions published too recently', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-release-age': '1.0.0', + }, + }, + { + '@angular-devkit-tests/update-release-age': { version: '1.0.0' }, + }, + ); + + const planNoFilter = await resolvePlan( + { + packages: ['@angular-devkit-tests/update-release-age'], + workspaceRoot: tempRoot, + }, + 0, + ); + + expect(planNoFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe( + '1.2.0', + ); + + const planWithFilter = await resolvePlan( + { + packages: ['@angular-devkit-tests/update-release-age'], + workspaceRoot: tempRoot, + }, + 3 * 24 * 60 * 60 * 1000, + ); + + expect(planWithFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe( + '1.1.0', + ); + }); }); describe('RegistryClient', () => { diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index d99d2c950702..c53dcce4bb5d 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -18,15 +18,18 @@ import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; import { parseBunDependencies, + parseNpmBeforeDate, parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, parseNpmLikeMetadata, + parsePnpmReleaseAge, parseYarnClassicDependencies, parseYarnClassicError, parseYarnClassicManifest, parseYarnClassicMetadata, parseYarnModernDependencies, + parseYarnReleaseAge, } from './parsers'; /** @@ -87,6 +90,9 @@ export interface PackageManagerDescriptor { /** The command to list all installed dependencies. */ readonly listDependenciesCommand: readonly string[]; + /** The command to get the release age configuration value. */ + readonly getReleaseAgeConfigCommand?: readonly string[]; + /** The command to get the current package name. */ readonly getPackageNameCommand?: readonly string[]; @@ -125,6 +131,9 @@ export interface PackageManagerDescriptor { /** A function to parse the output when a command fails. */ getError?: (output: string, logger?: Logger) => ErrorInfo | null; + + /** A function to parse the output of the release age config command. */ + getReleaseAge?: (output: string, version: string) => number; }; /** A function that checks if a structured error represents a "package not found" error. */ @@ -174,6 +183,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json=true', '--all=true'], + getReleaseAgeConfigCommand: ['config', 'get', 'before'], getPackageNameCommand: ['pkg', 'get', 'name'], getManifestCommand: ['view', '--json'], viewCommandFieldArgFormatter: (fields) => [...fields], @@ -182,6 +192,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, + getReleaseAge: parseNpmBeforeDate, }, isNotFound: isKnownNotFound, }, @@ -200,6 +211,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }), versionCommand: ['--version'], listDependenciesCommand: ['info', '--name-only', '--json'], + getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'], getManifestCommand: ['npm', 'info', '--json'], viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], outputParsers: { @@ -207,6 +219,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, + getReleaseAge: parseYarnReleaseAge, }, isNotFound: isKnownNotFound, }, @@ -254,6 +267,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json'], + getReleaseAgeConfigCommand: ['config', 'get', 'minimum-release-age'], getPackageNameCommand: ['pkg', 'get', 'name'], getManifestCommand: ['view', '--json'], viewCommandFieldArgFormatter: (fields) => [...fields], @@ -262,6 +276,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, + getReleaseAge: parsePnpmReleaseAge, }, isNotFound: isKnownNotFound, }, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index afa2aa6b4c57..686bfa617ca8 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -93,6 +93,7 @@ export class PackageManager { readonly #initializationError?: Error; #dependencyCache: Map | null = null; #version: string | undefined; + #minimumReleaseAge: Promise | undefined; #activeTasks = 0; readonly #pendingTasks: (() => void)[] = []; readonly #maxConcurrent = 5; @@ -737,6 +738,38 @@ export class PackageManager { return { workingDirectory, cleanup }; } + + /** + * Gets the active release age gate limit in milliseconds. + * @returns A promise that resolves to the limit in milliseconds, or `0` if not set. + */ + async getMinimumReleaseAge(): Promise { + if (this.#minimumReleaseAge === undefined) { + this.#minimumReleaseAge = this.#resolveMinimumReleaseAge(); + } + + return this.#minimumReleaseAge; + } + + /** + * Resolves the active minimum release age by querying the package manager configuration + * and parsing the resulting setting. + * @returns A promise that resolves to the limit in milliseconds, or `0` if not set. + */ + async #resolveMinimumReleaseAge(): Promise { + if (this.descriptor.getReleaseAgeConfigCommand && this.descriptor.outputParsers.getReleaseAge) { + try { + const { stdout } = await this.#run(this.descriptor.getReleaseAgeConfigCommand); + const version = await this.getVersion(); + + return this.descriptor.outputParsers.getReleaseAge(stdout, version); + } catch { + // Ignore failures and fallback to 0 + } + } + + return 0; + } } /** diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 105fcf5930b0..7cf5657e53b6 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -258,4 +258,232 @@ describe('PackageManager', () => { expect(manifest).toEqual({ name: 'foo', version: '1.0.0' }); }); }); + + describe('getMinimumReleaseAge', () => { + it('should return 0 when the package manager descriptor does not support getReleaseAgeConfigCommand', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']); + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + + it('should query release age configuration value and parse npm before date parameter correctly', async () => { + const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; + const pm = new PackageManager(host, '/tmp', npmDescriptor); + const testDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('--version')) { + return Promise.resolve({ stdout: '10.8.0', stderr: '' }); + } + if (args.includes('before')) { + return Promise.resolve({ stdout: testDate, stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + // Allow +/- 2000ms variance due to Date.now() timing difference between mock and execution. + expect(age).toBeGreaterThanOrEqual(10 * 24 * 60 * 60 * 1000 - 2000); + expect(age).toBeLessThanOrEqual(10 * 24 * 60 * 60 * 1000 + 2000); + expect(runCommandSpy).toHaveBeenCalledWith( + 'npm', + ['config', 'get', 'before'], + jasmine.anything(), + ); + }); + + it('should query release age configuration value and parse pnpm minutes value correctly', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('--version')) { + return Promise.resolve({ stdout: '10.15.0', stderr: '' }); + } + if (args.includes('minimum-release-age')) { + return Promise.resolve({ stdout: '1440', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(1440 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledWith( + 'pnpm', + ['config', 'get', 'minimum-release-age'], + jasmine.anything(), + ); + }); + + it('should query release age configuration value and parse yarn duration values correctly', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('--version')) { + return Promise.resolve({ stdout: '4.5.0', stderr: '' }); + } + if (args.includes('npmMinimalAgeGate')) { + return Promise.resolve({ stdout: '3d', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(3 * 24 * 60 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledWith( + 'yarn', + ['config', 'get', 'npmMinimalAgeGate'], + jasmine.anything(), + ); + }); + + it('should query yarn configuration value and parse raw numbers as minutes', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('--version')) { + return Promise.resolve({ stdout: '4.5.0', stderr: '' }); + } + if (args.includes('npmMinimalAgeGate')) { + return Promise.resolve({ stdout: '3', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(3 * 60 * 1000); + }); + + it('should cache the release age after the first fetch', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('--version')) { + return Promise.resolve({ stdout: '10.15.0', stderr: '' }); + } + if (args.includes('minimum-release-age')) { + return Promise.resolve({ stdout: '1440', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age1 = await pm.getMinimumReleaseAge(); + const age2 = await pm.getMinimumReleaseAge(); + + expect(age1).toBe(1440 * 60 * 1000); + expect(age2).toBe(1440 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledTimes(2); + }); + + it('should default to 1440 minutes for pnpm v11+ when config is not specified', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('minimum-release-age')) { + return Promise.resolve({ stdout: 'undefined', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '11.2.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(1440 * 60 * 1000); + }); + + it('should default to 0 for pnpm v10 when config is not specified', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('minimum-release-age')) { + return Promise.resolve({ stdout: 'undefined', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '10.15.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + + it('should respect explicitly configured 0 value for pnpm v11+', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('minimum-release-age')) { + return Promise.resolve({ stdout: '0', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '11.2.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + + it('should default to 1440 minutes for yarn v4+ when config is not specified', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('npmMinimalAgeGate')) { + return Promise.resolve({ stdout: 'undefined', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '4.1.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(1440 * 60 * 1000); + }); + + it('should default to 0 for yarn v3 when config is not specified', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('npmMinimalAgeGate')) { + return Promise.resolve({ stdout: 'undefined', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '3.6.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + + it('should respect explicitly configured 0 value for yarn v4+', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.callFake((binary: string, args: readonly string[]) => { + if (args.includes('npmMinimalAgeGate')) { + return Promise.resolve({ stdout: '0', stderr: '' }); + } + if (args.includes('--version')) { + return Promise.resolve({ stdout: '4.1.0', stderr: '' }); + } + + return Promise.reject(new Error('Unknown command')); + }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + }); }); diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 6e4fbcf83028..cddb25235d24 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -664,3 +664,100 @@ export function parseYarnModernDependencies( return dependencies; } + +/** + * Parses the output of the pnpm minimum-release-age config command. + * @param output The string output to parse (in minutes). + * @param version The active package manager version string. + * @returns The duration in milliseconds. + */ +export function parsePnpmReleaseAge(output: string, version: string): number { + const value = output.trim(); + // If the setting is empty, or undefined/null string placeholder, no age limit is active. + if (!value || value === 'undefined' || value === 'null') { + // Starting with pnpm version 11, the default value for minimum-release-age is 24 hours (1440 minutes). + // In prior versions, it defaulted to 0. + const major = parseInt(version.split('.')[0], 10); + if (major >= 11) { + return 1440 * 60000; + } + + return 0; + } + + // PNPM config outputs duration values without a unit as minutes (e.g. 1440). + const minutes = parseInt(value, 10); + + return isNaN(minutes) ? 0 : minutes * 60000; +} + +/** + * Parses the output of the yarn npmMinimalAgeGate config command. + * @param output The string output to parse (duration string or minutes). + * @returns The duration in milliseconds. + */ +export function parseYarnReleaseAge(output: string, version: string): number { + const value = output.trim(); + // If the setting is empty, or undefined/null string placeholder, no age limit is active. + if (!value || value === 'undefined' || value === 'null') { + // Starting with yarn version 4, the default value for minimum-release-age is 24 hours (1440 minutes). + // In prior versions, it defaulted to 0. + const major = parseInt(version.split('.')[0], 10); + if (major >= 4) { + return 1440 * 60000; + } + + return 0; + } + + // Parse Yarn duration format (e.g. "3d", "24h") or a raw minute value. + const match = value.match(/^(\d+)(ms|s|m|h|d|w)?$/); + if (!match) { + return 0; + } + + const amount = parseInt(match[1], 10); + const unit = match[2]; + + if (unit) { + switch (unit) { + case 'ms': + return amount; + case 's': + return amount * 1000; + case 'm': + return amount * 60000; + case 'h': + return amount * 3600000; + case 'd': + return amount * 86400000; + case 'w': + return amount * 604800000; + } + } + + // Yarn unit-less config value is always minutes. + return amount * 60000; +} + +/** + * Parses the output of the npm before config option. + * Converts the absolute cutoff date into a relative age in milliseconds. + * @param output The date string to parse. + * @returns The age in milliseconds. + */ +export function parseNpmBeforeDate(output: string): number { + const trimmed = output.trim(); + if (!trimmed || trimmed === 'null' || trimmed === 'undefined') { + return 0; + } + + const parsedDate = Date.parse(trimmed); + if (isNaN(parsedDate)) { + return 0; + } + + const age = Date.now() - parsedDate; + + return age > 0 ? age : 0; +} diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index d8fac05c3700..563420c3cc13 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -8,13 +8,16 @@ import { parseBunDependencies, + parseNpmBeforeDate, parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, + parsePnpmReleaseAge, parseYarnClassicDependencies, parseYarnClassicError, parseYarnClassicManifest, parseYarnModernDependencies, + parseYarnReleaseAge, } from './parsers'; describe('parsers', () => { @@ -354,4 +357,69 @@ project node_modules expect(parseYarnModernDependencies('').size).toBe(0); }); }); + + describe('parsePnpmReleaseAge', () => { + it('should parse minutes correctly', () => { + expect(parsePnpmReleaseAge('1440', '10.15.0')).toBe(1440 * 60000); + expect(parsePnpmReleaseAge(' 10 ', '10.15.0')).toBe(10 * 60000); + }); + + it('should return 0 for undefined/null/empty values when version is < 11', () => { + expect(parsePnpmReleaseAge('', '10.15.0')).toBe(0); + expect(parsePnpmReleaseAge('undefined', '10.15.0')).toBe(0); + expect(parsePnpmReleaseAge('null', '10.15.0')).toBe(0); + }); + + it('should return 1440 minutes for undefined/null/empty values when version is >= 11', () => { + expect(parsePnpmReleaseAge('', '11.2.0')).toBe(1440 * 60000); + expect(parsePnpmReleaseAge('undefined', '11.2.0')).toBe(1440 * 60000); + expect(parsePnpmReleaseAge('null', '11.2.0')).toBe(1440 * 60000); + }); + + it('should return 0 for invalid inputs', () => { + expect(parsePnpmReleaseAge('abc', '10.15.0')).toBe(0); + }); + }); + + describe('parseYarnReleaseAge', () => { + it('should parse duration units correctly', () => { + expect(parseYarnReleaseAge('3d', '3.6.0')).toBe(3 * 86400000); + expect(parseYarnReleaseAge('24h', '3.6.0')).toBe(24 * 3600000); + expect(parseYarnReleaseAge('30m', '3.6.0')).toBe(30 * 60000); + expect(parseYarnReleaseAge('60s', '3.6.0')).toBe(60000); + expect(parseYarnReleaseAge('1w', '3.6.0')).toBe(604800000); + }); + + it('should parse raw numbers as minutes', () => { + expect(parseYarnReleaseAge('3', '3.6.0')).toBe(3 * 60000); + expect(parseYarnReleaseAge('60', '3.6.0')).toBe(60 * 60000); + }); + + it('should return 0 for undefined/null/empty values when version is < 4', () => { + expect(parseYarnReleaseAge('', '3.6.0')).toBe(0); + expect(parseYarnReleaseAge('undefined', '3.6.0')).toBe(0); + expect(parseYarnReleaseAge('null', '3.6.0')).toBe(0); + }); + + it('should return 1440 minutes for undefined/null/empty values when version is >= 4', () => { + expect(parseYarnReleaseAge('', '4.1.0')).toBe(1440 * 60000); + expect(parseYarnReleaseAge('undefined', '4.1.0')).toBe(1440 * 60000); + expect(parseYarnReleaseAge('null', '4.1.0')).toBe(1440 * 60000); + }); + }); + + describe('parseNpmBeforeDate', () => { + it('should parse date string and convert to age limit', () => { + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + const age = parseNpmBeforeDate(tenDaysAgo); + expect(age).toBeGreaterThanOrEqual(10 * 24 * 60 * 60 * 1000 - 1000); + expect(age).toBeLessThanOrEqual(10 * 24 * 60 * 60 * 1000 + 1000); + }); + + it('should return 0 for undefined/null/empty values', () => { + expect(parseNpmBeforeDate('')).toBe(0); + expect(parseNpmBeforeDate('undefined')).toBe(0); + expect(parseNpmBeforeDate('null')).toBe(0); + }); + }); });