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
10 changes: 9 additions & 1 deletion packages/angular/cli/src/package-managers/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +38,13 @@ export interface Host {
*/
readdir(path: string): Promise<string[]>;

/**
* 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<string>;

/**
* Creates a new, unique temporary directory.
* @returns A promise that resolves to the absolute path of the created directory.
Expand Down Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface PackageManagerDescriptor {
listDependencies: (stdout: string, logger?: Logger) => Map<string, InstalledPackage>;

/** 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;
Expand Down Expand Up @@ -122,7 +122,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
viewCommandFieldArgFormatter: (fields) => [...fields],
outputParsers: {
listDependencies: parseNpmLikeDependencies,
getPackageManifest: parseNpmLikeManifest,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
},
},
Expand All @@ -144,7 +144,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')],
outputParsers: {
listDependencies: parseYarnModernDependencies,
getPackageManifest: parseNpmLikeManifest,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
},
},
Expand All @@ -168,7 +168,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
getManifestCommand: ['info', '--json'],
outputParsers: {
listDependencies: parseYarnClassicDependencies,
getPackageManifest: parseYarnLegacyManifest,
getRegistryManifest: parseYarnLegacyManifest,
getRegistryMetadata: parseNpmLikeMetadata,
},
},
Expand All @@ -190,7 +190,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
viewCommandFieldArgFormatter: (fields) => [...fields],
outputParsers: {
listDependencies: parseNpmLikeDependencies,
getPackageManifest: parseNpmLikeManifest,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
},
},
Expand All @@ -212,7 +212,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
viewCommandFieldArgFormatter: (fields) => [...fields],
outputParsers: {
listDependencies: parseNpmLikeDependencies,
getPackageManifest: parseNpmLikeManifest,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
},
},
Expand Down
92 changes: 86 additions & 6 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 } = {},
Expand All @@ -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<PackageManifest | null> {
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 `<temp>/node_modules/<name>`.
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<void> }> {
const workingDirectory = await this.host.createTempDirectory();
const cleanup = () => this.host.deleteDirectory(workingDirectory);
Expand All @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,8 @@ export class MockHost implements Host {
writeFile(): Promise<void> {
throw new Error('Method not implemented.');
}

readFile(): Promise<string> {
throw new Error('Method not implemented.');
}
}