From c71ffead8bf1bb97afa2f8bb163d402672eda7ce Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:26:46 -0500 Subject: [PATCH] refactor(@angular/cli): add generic package manifest resolver Extends the package manager abstraction to resolve manifests for various specifier types beyond registry packages. This is crucial for supporting packages installed from local directories, file archives, and git repositories. A new `getManifest` method is introduced on the `PackageManager` service. This method acts as a unified entry point for manifest resolution: - It uses `npm-package-arg` to parse the package specifier and determine its type (registry, directory, file, git, etc.). - For local directory specifiers, it reads the `package.json` directly from the filesystem for maximum efficiency. - For other non-registry specifiers (git repos, remote tarballs, local tarballs), it uses the `acquireTempPackage` flow to safely fetch and extract the package. - When acquiring a package, lifecycle scripts are disabled for improved security and performance. - To reliably determine the package name for non-registry types, the manifest resolver now inspects the temporary `package.json` after the package manager has installed the dependency. The existing `getPackageManifest` method has been renamed to `getRegistryManifest` to more accurately reflect its purpose. --- .../angular/cli/src/package-managers/host.ts | 10 +- .../package-manager-descriptor.ts | 12 +-- .../src/package-managers/package-manager.ts | 92 +++++++++++++++++-- .../src/package-managers/testing/mock-host.ts | 4 + 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 1295154ceacf..1d4b441bdc37 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -15,7 +15,7 @@ import { spawn } from 'node:child_process'; import { Stats } from 'node:fs'; -import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PackageManagerError } from './error'; @@ -38,6 +38,13 @@ export interface Host { */ readdir(path: string): Promise; + /** + * Reads the content of a file. + * @param path The path to the file. + * @returns A promise that resolves to the file content as a string. + */ + readFile(path: string): Promise; + /** * Creates a new, unique temporary directory. * @returns A promise that resolves to the absolute path of the created directory. @@ -85,6 +92,7 @@ export interface Host { export const NodeJS_HOST: Host = { stat, readdir, + readFile: (path: string) => readFile(path, { encoding: 'utf8' }), writeFile, createTempDirectory: () => mkdtemp(join(tmpdir(), 'angular-cli-')), deleteDirectory: (path: string) => rm(path, { recursive: true, force: true }), 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 62a6ae8b79b6..e64b4c8be92c 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -82,7 +82,7 @@ export interface PackageManagerDescriptor { listDependencies: (stdout: string, logger?: Logger) => Map; /** A function to parse the output of `getManifestCommand` for a specific version. */ - getPackageManifest: (stdout: string, logger?: Logger) => PackageManifest | null; + getRegistryManifest: (stdout: string, logger?: Logger) => PackageManifest | null; /** A function to parse the output of `getManifestCommand` for the full package metadata. */ getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null; @@ -122,7 +122,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { listDependencies: parseNpmLikeDependencies, - getPackageManifest: parseNpmLikeManifest, + getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, }, }, @@ -144,7 +144,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], outputParsers: { listDependencies: parseYarnModernDependencies, - getPackageManifest: parseNpmLikeManifest, + getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, }, }, @@ -168,7 +168,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getManifestCommand: ['info', '--json'], outputParsers: { listDependencies: parseYarnClassicDependencies, - getPackageManifest: parseYarnLegacyManifest, + getRegistryManifest: parseYarnLegacyManifest, getRegistryMetadata: parseNpmLikeMetadata, }, }, @@ -190,7 +190,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { listDependencies: parseNpmLikeDependencies, - getPackageManifest: parseNpmLikeManifest, + getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, }, }, @@ -212,7 +212,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { listDependencies: parseNpmLikeDependencies, - getPackageManifest: parseNpmLikeManifest, + getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, }, }, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 4f4620994769..02a4f8d4c853 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -13,6 +13,7 @@ */ import { join } from 'node:path'; +import npa from 'npm-package-arg'; import { PackageManagerError } from './error'; import { Host } from './host'; import { Logger } from './logger'; @@ -353,7 +354,7 @@ export class PackageManager { * @param options.bypassCache If true, ignores the in-memory cache and fetches fresh data. * @returns A promise that resolves to the `PackageManifest` object, or `null` if the package is not found. */ - async getPackageManifest( + async getRegistryManifest( packageName: string, version: string, options: { timeout?: number; registry?: string; bypassCache?: boolean } = {}, @@ -369,24 +370,100 @@ export class PackageManager { return this.#fetchAndParse( commandArgs, - (stdout, logger) => this.descriptor.outputParsers.getPackageManifest(stdout, logger), + (stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger), { ...options, cache: this.#manifestCache, cacheKey }, ); } + /** + * Fetches the manifest for a package. + * + * This method can resolve manifests for packages from the registry, as well + * as those specified by file paths, directory paths, and remote tarballs. + * Caching is only supported for registry packages. + * + * @param specifier The package specifier to resolve the manifest for. + * @param options Options for the fetch. + * @returns A promise that resolves to the `PackageManifest` object, or `null` if the package is not found. + */ + async getManifest( + specifier: string | npa.Result, + options: { timeout?: number; registry?: string; bypassCache?: boolean } = {}, + ): Promise { + const { name, type, fetchSpec } = typeof specifier === 'string' ? npa(specifier) : specifier; + + switch (type) { + case 'range': + case 'version': + 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); + case 'directory': { + if (!fetchSpec) { + throw new Error(`Could not parse directory path from specifier: ${specifier}`); + } + + const manifestPath = join(fetchSpec, 'package.json'); + const manifest = await this.host.readFile(manifestPath); + + return JSON.parse(manifest); + } + case 'file': + case 'remote': + case 'git': { + if (!fetchSpec) { + throw new Error(`Could not parse location from specifier: ${specifier}`); + } + + // Caching is not supported for non-registry specifiers. + const { workingDirectory, cleanup } = await this.acquireTempPackage(fetchSpec, { + ...options, + ignoreScripts: true, + }); + + try { + // Discover the package name by reading the temporary `package.json` file. + // The package manager will have added the package to the `dependencies`. + const tempManifest = await this.host.readFile(join(workingDirectory, 'package.json')); + const { dependencies } = JSON.parse(tempManifest) as PackageManifest; + const packageName = dependencies && Object.keys(dependencies)[0]; + + if (!packageName) { + throw new Error(`Could not determine package name for specifier: ${specifier}`); + } + + // The package will be installed in `/node_modules/`. + const packagePath = join(workingDirectory, 'node_modules', packageName); + const manifestPath = join(packagePath, 'package.json'); + const manifest = await this.host.readFile(manifestPath); + + return JSON.parse(manifest); + } finally { + await cleanup(); + } + } + default: + throw new Error(`Unsupported package specifier type: ${type}`); + } + } + /** * Acquires a package by installing it into a temporary directory. The caller is * responsible for managing the lifecycle of the temporary directory by calling * the returned `cleanup` function. * - * @param packageName The name of the package to install. + * @param specifier The specifier of the package to install. * @param options Options for the installation. * @returns A promise that resolves to an object containing the temporary path * and a cleanup function. */ async acquireTempPackage( - packageName: string, - options: { registry?: string } = {}, + specifier: string, + options: { registry?: string; ignoreScripts?: boolean } = {}, ): Promise<{ workingDirectory: string; cleanup: () => Promise }> { const workingDirectory = await this.host.createTempDirectory(); const cleanup = () => this.host.deleteDirectory(workingDirectory); @@ -396,7 +473,10 @@ export class PackageManager { // Writing an empty package.json file beforehand prevents this. await this.host.writeFile(join(workingDirectory, 'package.json'), '{}'); - const args: readonly string[] = [this.descriptor.addCommand, packageName]; + const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter( + (flag) => flag, + ); + const args: readonly string[] = [this.descriptor.addCommand, specifier, ...flags]; try { await this.#run(args, { ...options, cwd: workingDirectory }); diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts index 69b252501850..af518553a61d 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -58,4 +58,8 @@ export class MockHost implements Host { writeFile(): Promise { throw new Error('Method not implemented.'); } + + readFile(): Promise { + throw new Error('Method not implemented.'); + } }