Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
parseYarnClassicMetadata,
parseYarnModernDependencies,
parseYarnModernError,
parseYarnModernManifest,
parseYarnReleaseAge,
} from './parsers';

Expand Down Expand Up @@ -137,7 +138,11 @@ export interface PackageManagerDescriptor {
) => Map<string, InstalledPackage>;

/** A function to parse the output of `getManifestCommand` for a specific version. */
getRegistryManifest: (stdout: string, logger?: Logger) => PackageManifest | null;
getRegistryManifest: (
stdout: string,
logger?: Logger,
options?: { version?: string },
) => PackageManifest | null;

/** A function to parse the output of `getManifestCommand` for the full package metadata. */
getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null;
Expand Down Expand Up @@ -227,10 +232,15 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependenciesCommand: ['info', '--name-only', '--json'],
getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'],
getManifestCommand: ['npm', 'info', '--json'],
viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')],
// Always include `dist-tags` so `parseYarnModernManifest` can detect when `yarn npm info`
// silently falls back to `latest` for a non-existent dist-tag.
viewCommandFieldArgFormatter: (fields) => [
'--fields',
[...new Set([...fields, 'dist-tags'])].join(','),
],
outputParsers: {
listDependencies: parseYarnModernDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryManifest: parseYarnModernManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseYarnModernError,
getReleaseAge: parseYarnReleaseAge,
Expand Down
54 changes: 38 additions & 16 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,18 @@ export class PackageManager {
return metadata;
}

/**
* Creates the in-memory cache key for a registry manifest.
*
* The key is intentionally based on the unquoted `name@version` specifier so that it is
* stable regardless of whether the host requires shell quoting for command arguments.
*/
#getManifestCacheKey(packageName: string, version: string, registry?: string): string {
const specifier = `${packageName}@${version}`;

return registry ? `${specifier}|${registry}` : specifier;
}

/**
* Fetches the registry manifest for a specific version of a package.
* The manifest is similar to the package's `package.json` file.
Expand All @@ -558,30 +570,40 @@ export class PackageManager {
commandArgs.push(...formatter(MANIFEST_FIELDS));
}

const cacheKey = options.registry ? `${specifier}|${options.registry}` : specifier;
const cacheKey = this.#getManifestCacheKey(packageName, version, options.registry);
const normalizedRange = validRange(version);

const manifest = await this.#fetchAndParse(
commandArgs,
(stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger),
(stdout, logger) => {
const parsed = this.descriptor.outputParsers.getRegistryManifest(stdout, logger, {
version,
});
// Modern yarn returns the latest version instead of an error when a requested
// version or range does not exist. Validating inside the parser ensures the rejected
// result is cached as `null`. Dist-tag fallbacks cannot be detected here and are
// validated by the descriptor's parser instead (see `parseYarnModernManifest`).
if (
parsed &&
normalizedRange &&
!satisfies(parsed.version, normalizedRange, {
includePrerelease: normalizedRange === '*',
})
) {
return null;
}

return parsed;
},
{ ...options, cache: this.#manifestCache, cacheKey },
);

// Modern yarn returns the latest version instead of an error when the requested one does not exist.
if (
manifest &&
validRange(version) &&
!satisfies(manifest.version, version, { includePrerelease: true })
) {
return null;
}

// If the provided version was not a specific version, also cache the specific fetched version
if (manifest && manifest.version !== version) {
const manifestSpecifier = `${manifest.name}@${manifest.version}`;
const manifestCacheKey = options.registry
? `${manifestSpecifier}|${options.registry}`
: manifestSpecifier;
this.#manifestCache.set(manifestCacheKey, manifest);
this.#manifestCache.set(
this.#getManifestCacheKey(manifest.name, manifest.version, options.registry),
manifest,
);
}

return manifest;
Expand Down
147 changes: 147 additions & 0 deletions packages/angular/cli/src/package-managers/package-manager_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,120 @@ describe('PackageManager', () => {

expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
expect(await pm.getRegistryManifest('foo', '^99.0.0')).toBeNull();
expect(await pm.getRegistryManifest('foo', '2.0.0-rc.1')).toBeNull();
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toEqual(
jasmine.objectContaining({ version: '2.0.0' }),
);
});

it('should cache null for mismatched versions without caching the fallback manifest', async () => {
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({ name: 'foo', version: '2.0.0' }),
stderr: '',
});

expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
expect(runCommandSpy).toHaveBeenCalledTimes(1);

// Ensure the rejected fallback manifest was not cached under `foo@2.0.0`.
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({ name: 'foo', version: '2.0.0', homepage: 'https://angular.dev' }),
stderr: '',
});
expect(await pm.getRegistryManifest('foo', '2.0.0')).toEqual({
name: 'foo',
version: '2.0.0',
homepage: 'https://angular.dev',
});
expect(runCommandSpy).toHaveBeenCalledTimes(2);
});

it('should validate dist-tags against fetched dist-tags metadata and strip dist-tags from the manifest', async () => {
// Modern yarn falls back to `latest` when a requested dist-tag does not exist.
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({
name: 'foo',
version: '2.0.0',
'dist-tags': { latest: '2.0.0', next: '2.1.0-next.0' },
}),
stderr: '',
});

expect(await pm.getRegistryManifest('foo', 'nonexistent-tag')).toBeNull();
expect(await pm.getManifest('foo@nonexistent-tag')).toBeNull();
// If a package manager returned `2.0.0` when `next` (`2.1.0-next.0`) was requested, reject it.
expect(await pm.getRegistryManifest('foo', 'next')).toBeNull();

expect(await pm.getRegistryManifest('foo', 'latest')).toEqual({
name: 'foo',
version: '2.0.0',
});

expect(runCommandSpy).toHaveBeenCalledWith(
'yarn',
[
'npm',
'info',
'--json',
'foo@latest',
'--fields',
[...MANIFEST_FIELDS, 'dist-tags'].join(','),
],
jasmine.anything(),
);
});

it('should handle prerelease manifests accurately for wildcard, prerelease, and stable ranges', async () => {
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }),
stderr: '',
});

// Unmet stable major/x-ranges should not accept a prerelease `latest` fallback.
expect(await pm.getRegistryManifest('foo', '2')).toBeNull();
expect(await pm.getRegistryManifest('foo', '2.x')).toBeNull();
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull();

// Wildcard (`*`, produced by bare specifiers) and matching prerelease specs should succeed.
expect(await pm.getRegistryManifest('foo', '*')).toEqual({
name: 'foo',
version: '2.0.0-beta.1',
});
expect(await pm.getRegistryManifest('foo', '2.0.0-beta.1')).toEqual({
name: 'foo',
version: '2.0.0-beta.1',
});
expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({
name: 'foo',
version: '2.0.0-beta.1',
});
});

it('should apply the same prerelease range handling to non-yarn package managers', async () => {
const pm = new PackageManager(host, '/tmp', descriptor);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }),
stderr: '',
});

// A prerelease does not satisfy a stable range without `includePrerelease`.
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull();

// Wildcard and matching prerelease ranges accept the prerelease.
expect(await pm.getRegistryManifest('foo', '*')).toEqual({
name: 'foo',
version: '2.0.0-beta.1',
});
expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({
name: 'foo',
version: '2.0.0-beta.1',
});
});

it('should quote complex range specifiers when required by the host', async () => {
// Simulate a quoting host
Object.assign(host, { requiresQuoting: true });
Expand Down Expand Up @@ -71,6 +180,23 @@ describe('PackageManager', () => {
);
});

it('should reuse the resolved version cache entry when the host requires quoting', async () => {
Object.assign(host, { requiresQuoting: true });

const pm = new PackageManager(host, '/tmp', descriptor);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({ name: 'foo', version: '1.5.0' }),
stderr: '',
});

await pm.getRegistryManifest('foo', '^1.0.0');
expect(await pm.getRegistryManifest('foo', '1.5.0')).toEqual({
name: 'foo',
version: '1.5.0',
});
expect(runCommandSpy).toHaveBeenCalledTimes(1);
});

it('should return null when bun pm view fails with No matching version found', async () => {
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']);
runCommandSpy.and.rejectWith(
Expand Down Expand Up @@ -557,6 +683,27 @@ describe('PackageManager', () => {
'The remote server failed to provide the requested resource',
);
});

it('should not duplicate the dist-tags field when fetching yarn metadata', async () => {
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
runCommandSpy.and.resolveTo({
stdout: JSON.stringify({
name: 'foo',
'dist-tags': { latest: '1.0.0' },
versions: ['1.0.0'],
time: {},
}),
stderr: '',
});

await pm.getRegistryMetadata('foo');

expect(runCommandSpy).toHaveBeenCalledWith(
'yarn',
['npm', 'info', '--json', 'foo', '--fields', 'name,dist-tags,versions,time'],
jasmine.anything(),
);
});
});

describe('getCurrentPackageName', () => {
Expand Down
71 changes: 70 additions & 1 deletion packages/angular/cli/src/package-managers/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { stripVTControlCharacters } from 'node:util';
import { compare, valid } from 'semver';
import { compare, valid, validRange } from 'semver';
import { ErrorInfo } from './error';
import { Logger } from './logger';
import { PackageManifest, PackageMetadata } from './package-metadata';
Expand Down Expand Up @@ -442,6 +442,75 @@ export function parseYarnClassicManifest(stdout: string, logger?: Logger): Packa
return manifest;
}

/**
* Parses the output of `yarn npm info` (modern yarn) to get a package manifest.
*
* Modern yarn silently falls back to `latest` when a requested dist-tag does not exist.
* When `options.version` is a dist-tag (not a valid SemVer range), this parser verifies
* that the tag exists in the `dist-tags` output field and resolves to the returned manifest
* version. The `dist-tags` field is always stripped from the returned manifest.
*
* NOTE: This only handles the dist-tag case. The equivalent fallback for SemVer versions and
* ranges is handled for all package managers by `PackageManager.getRegistryManifest`.
*
* @param stdout The standard output of the command.
* @param logger An optional logger instance.
* @param options Optional context including the requested version or dist-tag.
* @returns The package manifest object, or `null` if not found or if the requested
* dist-tag could not be verified.
*/
export function parseYarnModernManifest(
stdout: string,
logger?: Logger,
options?: { version?: string },
): PackageManifest | null {
const parsed = parseNpmLikeManifest(stdout, logger);
if (!parsed) {
return null;
}

// Extract `dist-tags` (requested via `--fields` in the yarn descriptor) and omit it
// from the returned `PackageManifest` so packument-level metadata does not leak out.
const { 'dist-tags': distTags, ...manifest } = parsed as PackageManifest & {
'dist-tags'?: unknown;
};

// SemVer versions and ranges are validated by `PackageManager.getRegistryManifest`.
// When `requestedVersion` is not a valid SemVer range, treat it as a dist-tag and verify
// that `yarn npm info` resolved that tag rather than silently falling back to `latest`.
const requestedVersion = options?.version;
if (requestedVersion === undefined || validRange(requestedVersion)) {
return manifest;
}

// The yarn descriptor always requests `dist-tags`, so a missing or malformed field is
// unexpected. Fail closed since the tag cannot be verified.
if (!distTags || typeof distTags !== 'object' || Array.isArray(distTags)) {
logger?.debug(
` Cannot verify requested dist-tag '${requestedVersion}': 'dist-tags' is missing or invalid.`,
);

return null;
}

// Use `Object.hasOwn` to avoid matching inherited `Object.prototype` keys (e.g., `toString`).
const taggedVersion = Object.hasOwn(distTags, requestedVersion)
? (distTags as Record<string, unknown>)[requestedVersion]
: undefined;

// Reject if the tag does not exist in `dist-tags` or points to a different normalized
// SemVer version than the manifest that was returned.
if (typeof taggedVersion !== 'string' || valid(taggedVersion) !== valid(manifest.version)) {
logger?.debug(
` Requested dist-tag '${requestedVersion}' does not match manifest version '${manifest.version}'.`,
);

return null;
}

return manifest;
}

/**
* Parses the output of `yarn info` (classic) to get package metadata.
* @param stdout The standard output of the command.
Expand Down
Loading
Loading