From 845d9609c7e96e957f11e71280f84da540e3ecd2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:05:18 -0500 Subject: [PATCH] fix(@angular/cli): correctly handle yarn classic tag manifest fetching Introduces a `requiresManifestVersionLookup` property to `PackageManagerDescriptor` to control whether a package manager needs an explicit metadata lookup for tags, ranges, or when no `fetchSpec` is provided before attempting to fetch the full registry manifest. This change optimizes manifest fetching by enabling a preliminary metadata lookup for package managers like `yarn-classic` that require it to resolve tags and ranges to concrete versions. --- .../package-manager-descriptor.ts | 11 +++-- .../src/package-managers/package-manager.ts | 30 +++++++++++-- .../cli/src/package-managers/parsers.ts | 44 +++++++++++++++++-- 3 files changed, 76 insertions(+), 9 deletions(-) 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 e64b4c8be92c..85fa55707a11 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -20,7 +20,8 @@ import { parseNpmLikeManifest, parseNpmLikeMetadata, parseYarnClassicDependencies, - parseYarnLegacyManifest, + parseYarnClassicManifest, + parseYarnClassicMetadata, parseYarnModernDependencies, } from './parsers'; @@ -73,6 +74,9 @@ export interface PackageManagerDescriptor { /** The command to fetch the registry manifest of a package. */ readonly getManifestCommand: readonly string[]; + /** Whether a specific version lookup is needed prior to fetching a registry manifest. */ + readonly requiresManifestVersionLookup?: boolean; + /** A function that formats the arguments for field-filtered registry views. */ readonly viewCommandFieldArgFormatter?: (fields: readonly string[]) => string[]; @@ -166,10 +170,11 @@ export const SUPPORTED_PACKAGE_MANAGERS = { versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json'], getManifestCommand: ['info', '--json'], + requiresManifestVersionLookup: true, outputParsers: { listDependencies: parseYarnClassicDependencies, - getRegistryManifest: parseYarnLegacyManifest, - getRegistryMetadata: parseNpmLikeMetadata, + getRegistryManifest: parseYarnClassicManifest, + getRegistryMetadata: parseYarnClassicMetadata, }, }, pnpm: { diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 02a4f8d4c853..85d532850938 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import npa from 'npm-package-arg'; +import { maxSatisfying } from 'semver'; import { PackageManagerError } from './error'; import { Host } from './host'; import { Logger } from './logger'; @@ -156,12 +157,14 @@ export class PackageManager { return { stdout: '', stderr: '' }; } - return this.host.runCommand(this.descriptor.binary, finalArgs, { + const commandResult = await this.host.runCommand(this.descriptor.binary, finalArgs, { ...runOptions, cwd: executionDirectory, stdio: 'pipe', env: finalEnv, }); + + return { stdout: commandResult.stdout.trim(), stderr: commandResult.stderr.trim() }; } /** @@ -395,13 +398,34 @@ export class PackageManager { switch (type) { case 'range': case 'version': - case 'tag': + case 'tag': { if (!name) { throw new Error(`Could not parse package name from specifier: ${specifier}`); } // `fetchSpec` is the version, range, or tag. - return this.getRegistryManifest(name, fetchSpec ?? 'latest', options); + let versionSpec = fetchSpec ?? 'latest'; + if (this.descriptor.requiresManifestVersionLookup) { + if (type === 'tag' || !fetchSpec) { + const metadata = await this.getRegistryMetadata(name, options); + if (!metadata) { + return null; + } + versionSpec = metadata['dist-tags'][versionSpec]; + } else if (type === 'range') { + const metadata = await this.getRegistryMetadata(name, options); + if (!metadata) { + return null; + } + versionSpec = maxSatisfying(metadata.versions, fetchSpec) ?? ''; + } + if (!versionSpec) { + return null; + } + } + + return this.getRegistryManifest(name, versionSpec, options); + } case 'directory': { if (!fetchSpec) { throw new Error(`Could not parse directory path from specifier: ${specifier}`); diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index e14b455a4fe6..fd52402f1cf3 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -269,12 +269,12 @@ export function parseNpmLikeMetadata(stdout: string, logger?: Logger): PackageMe } /** - * Parses the output of `yarn info` (classic). + * Parses the output of `yarn info` (classic) to get a package manifest. * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns The package manifest object. */ -export function parseYarnLegacyManifest(stdout: string, logger?: Logger): PackageManifest | null { +export function parseYarnClassicManifest(stdout: string, logger?: Logger): PackageManifest | null { logger?.debug(`Parsing yarn classic manifest...`); logStdout(stdout, logger); @@ -287,5 +287,43 @@ export function parseYarnLegacyManifest(stdout: string, logger?: Logger): Packag const data = JSON.parse(stdout); // Yarn classic wraps the manifest in a `data` property. - return data.data ?? data; + const manifest = data.data as PackageManifest; + + // Yarn classic removes any field with a falsy value + // https://github.com/yarnpkg/yarn/blob/7cafa512a777048ce0b666080a24e80aae3d66a9/src/cli/commands/info.js#L26-L29 + // Add a default of 'false' for the `save` field when the `ng-add` object is present but does not have any fields. + // There is a small chance this causes an incorrect value. However, the use of `ng-add` is rare and, in the cases + // it is used, save is set to either a `false` literal or a truthy value. Special cases can be added for specific + // packages if discovered. + if ( + manifest['ng-add'] && + typeof manifest['ng-add'] === 'object' && + Object.keys(manifest['ng-add']).length === 0 + ) { + manifest['ng-add'].save ??= false; + } + + return manifest; +} + +/** + * Parses the output of `yarn info` (classic) to get package metadata. + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns The package metadata object. + */ +export function parseYarnClassicMetadata(stdout: string, logger?: Logger): PackageMetadata | null { + logger?.debug(`Parsing yarn classic metadata...`); + logStdout(stdout, logger); + + if (!stdout) { + logger?.debug(' stdout is empty. No metadata found.'); + + return null; + } + + const data = JSON.parse(stdout); + + // Yarn classic wraps the metadata in a `data` property. + return data.data; }