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.'); + } }