diff --git a/packages/angular/cli/src/package-managers/discovery.ts b/packages/angular/cli/src/package-managers/discovery.ts new file mode 100644 index 000000000000..c96a637e7eb1 --- /dev/null +++ b/packages/angular/cli/src/package-managers/discovery.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file contains the logic for discovering the package manager + * used in a project by searching for lockfiles. It is designed to be efficient + * and to correctly handle monorepo structures. + */ + +import { dirname, join } from 'node:path'; +import { Host } from './host'; +import { Logger } from './logger'; +import { + PACKAGE_MANAGER_PRECEDENCE, + PackageManagerName, + SUPPORTED_PACKAGE_MANAGERS, +} from './package-manager-descriptor'; + +/** + * A map from lockfile names to their corresponding package manager. + * This is a performance optimization to avoid iterating over all possible + * lockfiles in every directory. + */ +const LOCKFILE_TO_PACKAGE_MANAGER = new Map(); +for (const [name, descriptor] of Object.entries(SUPPORTED_PACKAGE_MANAGERS)) { + for (const lockfile of descriptor.lockfiles) { + LOCKFILE_TO_PACKAGE_MANAGER.set(lockfile, name as PackageManagerName); + } +} + +/** + * Searches a directory for lockfiles and returns a set of package managers that correspond to them. + * @param host A `Host` instance for interacting with the file system. + * @param directory The directory to search. + * @param logger An optional logger instance. + * @returns A promise that resolves to a set of package manager names. + */ +async function findLockfiles( + host: Host, + directory: string, + logger?: Logger, +): Promise> { + logger?.debug(`Searching for lockfiles in '${directory}'...`); + + try { + const files = await host.readdir(directory); + const foundPackageManagers = new Set(); + + for (const file of files) { + const packageManager = LOCKFILE_TO_PACKAGE_MANAGER.get(file); + if (packageManager) { + logger?.debug(` Found '${file}'.`); + foundPackageManagers.add(packageManager); + } + } + + return foundPackageManagers; + } catch (e) { + logger?.debug(` Failed to read directory: ${e}`); + + // Ignore directories that don't exist or can't be read. + return new Set(); + } +} + +/** + * Checks if a given path is a directory. + * @param host A `Host` instance for interacting with the file system. + * @param path The path to check. + * @returns A promise that resolves to true if the path is a directory, false otherwise. + */ +async function isDirectory(host: Host, path: string): Promise { + try { + return (await host.stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** + * Discovers the package manager used in a project by searching for lockfiles. + * + * This function searches for lockfiles in the given directory and its ancestors. + * If multiple lockfiles are found, it uses the precedence array to determine + * which package manager to use. The search is bounded by the git repository root. + * + * @param host A `Host` instance for interacting with the file system. + * @param startDir The directory to start the search from. + * @param logger An optional logger instance. + * @returns A promise that resolves to the name of the discovered package manager, or null if none is found. + */ +export async function discover( + host: Host, + startDir: string, + logger?: Logger, +): Promise { + logger?.debug(`Starting package manager discovery in '${startDir}'...`); + let currentDir = startDir; + + while (true) { + const found = await findLockfiles(host, currentDir, logger); + + if (found.size > 0) { + logger?.debug(`Found lockfile(s): [${[...found].join(', ')}]. Applying precedence...`); + for (const packageManager of PACKAGE_MANAGER_PRECEDENCE) { + if (found.has(packageManager)) { + logger?.debug(`Selected '${packageManager}' based on precedence.`); + + return packageManager; + } + } + } + + // Stop searching if we reach the git repository root. + if (await isDirectory(host, join(currentDir, '.git'))) { + logger?.debug(`Reached repository root at '${currentDir}'. Stopping search.`); + + return null; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + // We have reached the filesystem root. + logger?.debug('Reached filesystem root. No lockfile found.'); + + return null; + } + + currentDir = parentDir; + } +} diff --git a/packages/angular/cli/src/package-managers/discovery_spec.ts b/packages/angular/cli/src/package-managers/discovery_spec.ts new file mode 100644 index 000000000000..5570be1d614b --- /dev/null +++ b/packages/angular/cli/src/package-managers/discovery_spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { discover } from './discovery'; +import { MockHost } from './testing/mock-host'; + +describe('discover', () => { + it('should find a lockfile in the starting directory', async () => { + const host = new MockHost({ + '/project': ['package-lock.json'], + }); + const result = await discover(host, '/project'); + expect(result).toBe('npm'); + }); + + it('should find a lockfile in a parent directory', async () => { + const host = new MockHost({ + '/project': ['yarn.lock'], + '/project/subdir': [], + }); + const result = await discover(host, '/project/subdir'); + expect(result).toBe('yarn'); + }); + + it('should return null if no lockfile is found up to the root', async () => { + const host = new MockHost({ + '/': [], + '/project': [], + '/project/subdir': [], + }); + const result = await discover(host, '/project/subdir'); + expect(result).toBeNull(); + }); + + it('should apply precedence when multiple lockfiles are found', async () => { + const host = new MockHost({ + '/project': ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'], + }); + // pnpm should have the highest precedence according to the descriptor. + const result = await discover(host, '/project'); + expect(result).toBe('pnpm'); + }); + + it('should stop searching at a .git boundary', async () => { + const host = new MockHost({ + '/': ['yarn.lock'], + '/project/.git': true, // .git is mocked as a directory. + '/project/subdir': [], + }); + const result = await discover(host, '/project/subdir'); + expect(result).toBeNull(); + }); + + it('should stop searching at the filesystem root', async () => { + const host = new MockHost({ + '/': [], + }); + const result = await discover(host, '/'); + expect(result).toBeNull(); + }); + + it('should handle file system errors during readdir gracefully', async () => { + const host = new MockHost({}); + host.readdir = () => Promise.reject(new Error('Permission denied')); + + const result = await discover(host, '/project'); + expect(result).toBeNull(); + }); + + it('should handle file system errors during stat gracefully', async () => { + const host = new MockHost({ '/project': ['.git'] }); + host.stat = () => Promise.reject(new Error('Permission denied')); + + // The error on stat should prevent it from finding the .git dir and thus it will continue to the root. + const result = await discover(host, '/project'); + expect(result).toBeNull(); + }); + + it('should prioritize the closest lockfile, regardless of precedence', async () => { + const host = new MockHost({ + '/project': ['pnpm-lock.yaml'], // Higher precedence + '/project/subdir': ['package-lock.json'], // Lower precedence + }); + const result = await discover(host, '/project/subdir'); + // Should find 'npm' and stop, not continue to find 'pnpm'. + expect(result).toBe('npm'); + }); + + it('should find a lockfile in the git root directory', async () => { + const host = new MockHost({ + '/project': ['yarn.lock'], + '/project/.git': true, + '/project/subdir': [], + }); + const result = await discover(host, '/project/subdir'); + expect(result).toBe('yarn'); + }); + + it('should discover the alternate npm lockfile name', async () => { + const host = new MockHost({ + '/project': ['npm-shrinkwrap.json'], + }); + const result = await discover(host, '/project'); + expect(result).toBe('npm'); + }); + + it('should discover the alternate bun lockfile name', async () => { + const host = new MockHost({ + '/project': ['bun.lockb'], + }); + const result = await discover(host, '/project'); + expect(result).toBe('bun'); + }); +}); diff --git a/packages/angular/cli/src/package-managers/error.ts b/packages/angular/cli/src/package-managers/error.ts new file mode 100644 index 000000000000..c17af3f7cae3 --- /dev/null +++ b/packages/angular/cli/src/package-managers/error.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file defines a custom error class for the package manager + * abstraction. This allows for structured error handling and provides consumers + * with detailed information about the process failure. + */ + +/** + * A custom error class for package manager-related errors. + * + * This error class provides structured data about the failed process, + * including stdout, stderr, and the exit code. + */ +export class PackageManagerError extends Error { + /** + * Creates a new `PackageManagerError` instance. + * @param message The error message. + * @param stdout The standard output of the failed process. + * @param stderr The standard error of the failed process. + * @param exitCode The exit code of the failed process. + */ + constructor( + message: string, + public readonly stdout: string, + public readonly stderr: string, + public readonly exitCode: number | null, + ) { + super(message); + } +} diff --git a/packages/angular/cli/src/package-managers/factory.ts b/packages/angular/cli/src/package-managers/factory.ts new file mode 100644 index 000000000000..19ec32f7f886 --- /dev/null +++ b/packages/angular/cli/src/package-managers/factory.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { major } from 'semver'; +import { discover } from './discovery'; +import { Host, NodeJS_HOST } from './host'; +import { Logger } from './logger'; +import { PackageManager } from './package-manager'; +import { PackageManagerName, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; + +/** + * The default package manager to use when none is discovered or configured. + */ +const DEFAULT_PACKAGE_MANAGER: PackageManagerName = 'npm'; + +/** + * Gets the version of yarn installed on the system. + * @param host A `Host` instance for running commands. + * @param cwd The absolute path to the working directory. + * @param logger An optional logger instance. + * @returns A promise that resolves to the yarn version string, or null if yarn is not installed. + */ +async function getYarnVersion(host: Host, cwd: string, logger?: Logger): Promise { + logger?.debug(`Getting yarn version...`); + + try { + const { stdout } = await host.runCommand('yarn', ['--version'], { cwd }); + const version = stdout.trim(); + logger?.debug(`Yarn version is '${version}'.`); + + return version; + } catch (e) { + logger?.debug('Failed to get yarn version.'); + + return null; + } +} + +/** + * Determines the package manager to use for a given project. + * + * This function will determine the package manager by checking for a configured + * package manager, discovering the package manager from lockfiles, or falling + * back to a default. It also handles differentiation between yarn classic and modern. + * + * @param host A `Host` instance for interacting with the file system and running commands. + * @param cwd The directory to start the search from. + * @param configured An optional, explicitly configured package manager. + * @param logger An optional logger instance. + * @returns A promise that resolves to an object containing the name and source of the package manager. + */ +async function determinePackageManager( + host: Host, + cwd: string, + configured?: PackageManagerName, + logger?: Logger, + dryRun?: boolean, +): Promise<{ name: PackageManagerName; source: 'configured' | 'discovered' | 'default' }> { + let name: PackageManagerName; + let source: 'configured' | 'discovered' | 'default'; + + if (configured) { + name = configured; + source = 'configured'; + logger?.debug(`Using configured package manager: '${name}'.`); + } else { + const discovered = await discover(host, cwd, logger); + if (discovered) { + name = discovered; + source = 'discovered'; + logger?.debug(`Discovered package manager: '${name}'.`); + } else { + name = DEFAULT_PACKAGE_MANAGER; + source = 'default'; + logger?.debug( + `No lockfile found. Using default package manager: '${DEFAULT_PACKAGE_MANAGER}'.`, + ); + } + } + + if (name === 'yarn' && !dryRun) { + const version = await getYarnVersion(host, cwd, logger); + if (version && major(version) < 2) { + name = 'yarn-classic'; + logger?.debug(`Detected yarn classic. Using 'yarn-classic'.`); + } + } else if (name === 'yarn') { + logger?.debug('Skipping yarn version check due to dry run. Assuming modern yarn.'); + } + + return { name, source }; +} + +/** + * Creates a new `PackageManager` instance for a given project. + * + * This function is the main entry point for the package manager abstraction. + * It will determine, verify, and instantiate the correct package manager. + * + * @param options An object containing the options for creating the package manager. + * @returns A promise that resolves to a new `PackageManager` instance. + */ +export async function createPackageManager(options: { + cwd: string; + configuredPackageManager?: PackageManagerName; + logger?: Logger; + dryRun?: boolean; +}): Promise { + const { cwd, configuredPackageManager, logger, dryRun } = options; + const host = NodeJS_HOST; + + const { name, source } = await determinePackageManager( + host, + cwd, + configuredPackageManager, + logger, + dryRun, + ); + + const descriptor = SUPPORTED_PACKAGE_MANAGERS[name]; + if (!descriptor) { + throw new Error(`Unsupported package manager: "${name}"`); + } + + const packageManager = new PackageManager(host, cwd, descriptor, { dryRun, logger }); + + // Do not verify if the package manager is installed during a dry run. + if (!dryRun) { + try { + await packageManager.getVersion(); + } catch { + if (source === 'default') { + throw new Error( + `'${DEFAULT_PACKAGE_MANAGER}' was selected as the default package manager, but it is not installed or` + + ` cannot be found in the PATH. Please install '${DEFAULT_PACKAGE_MANAGER}' to continue.`, + ); + } else { + throw new Error( + `The project is configured to use '${name}', but it is not installed or cannot be` + + ` found in the PATH. Please install '${name}' to continue.`, + ); + } + } + } + + logger?.debug(`Successfully created PackageManager for '${name}'.`); + + return packageManager; +} diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts new file mode 100644 index 000000000000..1295154ceacf --- /dev/null +++ b/packages/angular/cli/src/package-managers/host.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * This file defines an abstraction layer for side-effectful operations, such as + * file system access and command execution. This allows for easier testing by + * enabling the injection of mock or test-specific implementations. + */ + +import { spawn } from 'node:child_process'; +import { Stats } from 'node:fs'; +import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PackageManagerError } from './error'; + +/** + * An abstraction layer for side-effectful operations. + */ +export interface Host { + /** + * Gets the stats of a file or directory. + * @param path The path to the file or directory. + * @returns A promise that resolves to the stats. + */ + stat(path: string): Promise; + + /** + * Reads the contents of a directory. + * @param path The path to the directory. + * @returns A promise that resolves to an array of file and directory names. + */ + readdir(path: string): Promise; + + /** + * Creates a new, unique temporary directory. + * @returns A promise that resolves to the absolute path of the created directory. + */ + createTempDirectory(): Promise; + + /** + * Deletes a directory recursively. + * @param path The path to the directory to delete. + * @returns A promise that resolves when the deletion is complete. + */ + deleteDirectory(path: string): Promise; + + /** + * Writes content to a file. + * @param path The path to the file. + * @param content The content to write. + * @returns A promise that resolves when the write is complete. + */ + writeFile(path: string, content: string): Promise; + + /** + * Spawns a child process and returns a promise that resolves with the process's + * output or rejects with a structured error. + * @param command The command to run. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns A promise that resolves with the standard output and standard error of the command. + */ + runCommand( + command: string, + args: readonly string[], + options?: { + timeout?: number; + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + }, + ): Promise<{ stdout: string; stderr: string }>; +} + +/** + * A concrete implementation of the `Host` interface that uses the Node.js APIs. + */ +export const NodeJS_HOST: Host = { + stat, + readdir, + writeFile, + createTempDirectory: () => mkdtemp(join(tmpdir(), 'angular-cli-')), + deleteDirectory: (path: string) => rm(path, { recursive: true, force: true }), + runCommand: async ( + command: string, + args: readonly string[], + options: { + timeout?: number; + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + } = {}, + ): Promise<{ stdout: string; stderr: string }> => { + const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; + + return new Promise((resolve, reject) => { + const childProcess = spawn(command, args, { + shell: false, + stdio: options.stdio ?? 'pipe', + signal, + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + }); + + let stdout = ''; + childProcess.stdout?.on('data', (data) => (stdout += data.toString())); + + let stderr = ''; + childProcess.stderr?.on('data', (data) => (stderr += data.toString())); + + childProcess.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + const message = `Process exited with code ${code}.`; + reject(new PackageManagerError(message, stdout, stderr, code)); + } + }); + + childProcess.on('error', (err) => { + if (err.name === 'AbortError') { + const message = `Process timed out.`; + reject(new PackageManagerError(message, stdout, stderr, null)); + + return; + } + const message = `Process failed with error: ${err.message}`; + reject(new PackageManagerError(message, stdout, stderr, null)); + }); + }); + }, +}; diff --git a/packages/angular/cli/src/package-managers/logger.ts b/packages/angular/cli/src/package-managers/logger.ts new file mode 100644 index 000000000000..25f34218c893 --- /dev/null +++ b/packages/angular/cli/src/package-managers/logger.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file defines a basic logger interface that is used by + * the package manager abstraction. This allows the abstraction to be decoupled + * from any specific logging implementation. + */ + +/** + * A basic logger interface for the package manager abstraction. + */ +export interface Logger { + /** + * Logs a debug message. + * @param message The message to log. + */ + debug(message: string): void; + + /** + * Logs an informational message. + * @param message The message to log. + */ + info(message: string): void; +} diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts new file mode 100644 index 000000000000..62a6ae8b79b6 --- /dev/null +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file defines the data structures and configuration for + * supported package managers. It is the single source of truth for all + * package-manager-specific commands, flags, and output parsing. + */ + +import { Logger } from './logger'; +import { PackageManifest, PackageMetadata } from './package-metadata'; +import { InstalledPackage } from './package-tree'; +import { + parseNpmLikeDependencies, + parseNpmLikeManifest, + parseNpmLikeMetadata, + parseYarnClassicDependencies, + parseYarnLegacyManifest, + parseYarnModernDependencies, +} from './parsers'; + +/** + * An interface that describes the commands and properties of a package manager. + */ +export interface PackageManagerDescriptor { + /** The binary executable for the package manager. */ + readonly binary: string; + + /** The lockfile names used by the package manager. */ + readonly lockfiles: readonly string[]; + + /** The command to add a package. */ + readonly addCommand: string; + + /** The command to install all dependencies. */ + readonly installCommand: readonly string[]; + + /** The flag to force a clean installation. */ + readonly forceFlag: string; + + /** The flag to save a package with an exact version. */ + readonly saveExactFlag: string; + + /** The flag to save a package with a tilde version range. */ + readonly saveTildeFlag: string; + + /** The flag to save a package as a dev dependency. */ + readonly saveDevFlag: string; + + /** The flag to prevent the lockfile from being updated. */ + readonly noLockfileFlag: string; + + /** The flag to prevent lifecycle scripts from being executed. */ + readonly ignoreScriptsFlag: string; + + /** A function that returns the arguments and environment variables to use a custom registry. */ + readonly getRegistryOptions?: (registry: string) => { + args?: string[]; + env?: Record; + }; + + /** The command to get the package manager's version. */ + readonly versionCommand: readonly string[]; + + /** The command to list all installed dependencies. */ + readonly listDependenciesCommand: readonly string[]; + + /** The command to fetch the registry manifest of a package. */ + readonly getManifestCommand: readonly string[]; + + /** A function that formats the arguments for field-filtered registry views. */ + readonly viewCommandFieldArgFormatter?: (fields: readonly string[]) => string[]; + + /** A collection of functions to parse the output of specific commands. */ + readonly outputParsers: { + /** A function to parse the output of `listDependenciesCommand`. */ + 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; + + /** A function to parse the output of `getManifestCommand` for the full package metadata. */ + getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null; + }; +} + +/** A type that represents the name of a supported package manager. */ +export type PackageManagerName = keyof typeof SUPPORTED_PACKAGE_MANAGERS; + +/** + * A map of supported package managers to their descriptors. + * This is the single source of truth for all package-manager-specific + * configuration and behavior. + * + * Each descriptor is intentionally explicit and self-contained. This approach + * avoids inheritance or fallback logic between package managers, ensuring that + * the behavior for each one is clear, predictable, and easy to modify in + * isolation. For example, `yarn-classic` does not inherit any properties from + * the `yarn` descriptor; it is a complete and independent definition. + */ +export const SUPPORTED_PACKAGE_MANAGERS = { + npm: { + binary: 'npm', + lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], + addCommand: 'install', + installCommand: ['install'], + forceFlag: '--force', + saveExactFlag: '--save-exact', + saveTildeFlag: '--save-tilde', + saveDevFlag: '--save-dev', + noLockfileFlag: '--no-package-lock', + ignoreScriptsFlag: '--ignore-scripts', + getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), + versionCommand: ['--version'], + listDependenciesCommand: ['list', '--depth=0', '--json=true', '--all=true'], + getManifestCommand: ['view', '--json'], + viewCommandFieldArgFormatter: (fields) => [...fields], + outputParsers: { + listDependencies: parseNpmLikeDependencies, + getPackageManifest: parseNpmLikeManifest, + getRegistryMetadata: parseNpmLikeMetadata, + }, + }, + yarn: { + binary: 'yarn', + lockfiles: ['yarn.lock'], + addCommand: 'add', + installCommand: ['install'], + forceFlag: '--force', + saveExactFlag: '--exact', + saveTildeFlag: '--tilde', + saveDevFlag: '--dev', + noLockfileFlag: '--no-lockfile', + ignoreScriptsFlag: '--ignore-scripts', + getRegistryOptions: (registry: string) => ({ env: { NPM_CONFIG_REGISTRY: registry } }), + versionCommand: ['--version'], + listDependenciesCommand: ['list', '--depth=0', '--json', '--recursive=false'], + getManifestCommand: ['npm', 'info', '--json'], + viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], + outputParsers: { + listDependencies: parseYarnModernDependencies, + getPackageManifest: parseNpmLikeManifest, + getRegistryMetadata: parseNpmLikeMetadata, + }, + }, + 'yarn-classic': { + binary: 'yarn', + // This is intentionally empty. `yarn-classic` is not a discoverable package manager. + // The discovery process finds `yarn` via `yarn.lock`, and the factory logic + // determines whether it is classic or modern by checking the installed version. + lockfiles: [], + addCommand: 'add', + installCommand: ['install'], + forceFlag: '--force', + saveExactFlag: '--exact', + saveTildeFlag: '--tilde', + saveDevFlag: '--dev', + noLockfileFlag: '--no-lockfile', + ignoreScriptsFlag: '--ignore-scripts', + getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), + versionCommand: ['--version'], + listDependenciesCommand: ['list', '--depth=0', '--json'], + getManifestCommand: ['info', '--json'], + outputParsers: { + listDependencies: parseYarnClassicDependencies, + getPackageManifest: parseYarnLegacyManifest, + getRegistryMetadata: parseNpmLikeMetadata, + }, + }, + pnpm: { + binary: 'pnpm', + lockfiles: ['pnpm-lock.yaml'], + addCommand: 'add', + installCommand: ['install'], + forceFlag: '--force', + saveExactFlag: '--save-exact', + saveTildeFlag: '--save-tilde', + saveDevFlag: '--save-dev', + noLockfileFlag: '--no-lockfile', + ignoreScriptsFlag: '--ignore-scripts', + getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), + versionCommand: ['--version'], + listDependenciesCommand: ['list', '--depth=0', '--json'], + getManifestCommand: ['view', '--json'], + viewCommandFieldArgFormatter: (fields) => [...fields], + outputParsers: { + listDependencies: parseNpmLikeDependencies, + getPackageManifest: parseNpmLikeManifest, + getRegistryMetadata: parseNpmLikeMetadata, + }, + }, + bun: { + binary: 'bun', + lockfiles: ['bun.lockb', 'bun.lock'], + addCommand: 'add', + installCommand: ['install'], + forceFlag: '--force', + saveExactFlag: '--exact', + saveTildeFlag: '', // Bun does not have a flag for tilde, it defaults to caret. + saveDevFlag: '--development', + noLockfileFlag: '', // Bun does not have a flag for this. + ignoreScriptsFlag: '--ignore-scripts', + getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), + versionCommand: ['--version'], + listDependenciesCommand: ['pm', 'ls', '--json'], + getManifestCommand: ['pm', 'view', '--json'], + viewCommandFieldArgFormatter: (fields) => [...fields], + outputParsers: { + listDependencies: parseNpmLikeDependencies, + getPackageManifest: parseNpmLikeManifest, + getRegistryMetadata: parseNpmLikeMetadata, + }, + }, +} satisfies Record; + +/** + * The order of precedence for package managers. + * This is a best-effort ordering based on estimated Angular community usage and default presence. + */ +export const PACKAGE_MANAGER_PRECEDENCE: readonly PackageManagerName[] = [ + 'pnpm', + 'yarn', + 'bun', + 'npm', +]; diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts new file mode 100644 index 000000000000..f6ec79da0865 --- /dev/null +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -0,0 +1,410 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file contains the `PackageManager` class, which is the + * core execution engine for all package manager commands. It is designed to be + * a flexible and secure abstraction over the various package managers. + */ + +import { join } from 'node:path'; +import { PackageManagerError } from './error'; +import { Host } from './host'; +import { Logger } from './logger'; +import { PackageManagerDescriptor } from './package-manager-descriptor'; +import { PackageManifest, PackageMetadata } from './package-metadata'; +import { InstalledPackage } from './package-tree'; + +/** + * The fields to request from the registry for package metadata. + * This is a performance optimization to avoid downloading the full manifest + * when only summary data (like versions and tags) is needed. + */ +const METADATA_FIELDS = ['name', 'dist-tags', 'versions', 'time'] as const; + +/** + * The fields to request from the registry for a package's manifest. + * This is a performance optimization to avoid downloading unnecessary data. + * These fields are the ones required by the CLI for operations like `ng add` and `ng update`. + */ +const MANIFEST_FIELDS = [ + 'name', + 'version', + 'dependencies', + 'peerDependencies', + 'devDependencies', + 'homepage', + 'schematics', + 'ng-add', + 'ng-update', +] as const; + +/** + * Options to configure the `PackageManager` instance. + */ +export interface PackageManagerOptions { + /** + * If true, no commands will be executed, but they will be logged to the logger. + * A logger must be provided if this is true. + */ + dryRun?: boolean; + + /** A logger instance for debugging and dry run output. */ + logger?: Logger; +} + +/** + * A class that provides a high-level, package-manager-agnostic API for + * interacting with a project's dependencies. + * + * This class is an implementation of the Strategy design pattern. It is + * instantiated with a `PackageManagerDescriptor` that defines the specific + * commands and flags for a given package manager. + */ +export class PackageManager { + readonly #manifestCache = new Map(); + readonly #metadataCache = new Map(); + #dependencyCache: Map | null = null; + + /** + * Creates a new `PackageManager` instance. + * @param host A `Host` instance for interacting with the file system and running commands. + * @param cwd The absolute path to the project's working directory. + * @param descriptor A `PackageManagerDescriptor` that defines the commands for a specific package manager. + * @param options An options object to configure the instance. + */ + constructor( + private readonly host: Host, + private readonly cwd: string, + private readonly descriptor: PackageManagerDescriptor, + private readonly options: PackageManagerOptions = {}, + ) { + if (this.options.dryRun && !this.options.logger) { + throw new Error('A logger must be provided when dryRun is enabled.'); + } + } + + /** + * The name of the package manager's binary. + */ + get name(): string { + return this.descriptor.binary; + } + + /** + * A private method to lazily populate the dependency cache. + * This is a performance optimization to avoid running `npm list` multiple times. + * @returns A promise that resolves to the dependency cache map. + */ + async #populateDependencyCache(): Promise> { + if (this.#dependencyCache !== null) { + return this.#dependencyCache; + } + + const args = this.descriptor.listDependenciesCommand; + + const dependencies = await this.#fetchAndParse(args, (stdout, logger) => + this.descriptor.outputParsers.listDependencies(stdout, logger), + ); + + return (this.#dependencyCache = dependencies ?? new Map()); + } + + /** + * A private method to run a command using the package manager's binary. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns A promise that resolves with the standard output and standard error of the command. + */ + async #run( + args: readonly string[], + options: { timeout?: number; registry?: string; cwd?: string } = {}, + ): Promise<{ stdout: string; stderr: string }> { + const { registry, cwd, ...runOptions } = options; + const finalArgs = [...args]; + let finalEnv: Record | undefined; + + if (registry) { + const registryOptions = this.descriptor.getRegistryOptions?.(registry); + if (!registryOptions) { + throw new Error( + `The configured package manager, '${this.descriptor.binary}', does not support a custom registry.`, + ); + } + + if (registryOptions.args) { + finalArgs.push(...registryOptions.args); + } + if (registryOptions.env) { + finalEnv = registryOptions.env; + } + } + + const executionDirectory = cwd ?? this.cwd; + if (this.options.dryRun) { + this.options.logger?.info( + `[DRY RUN] Would execute in [${executionDirectory}]: ${this.descriptor.binary} ${finalArgs.join(' ')}`, + ); + + return { stdout: '', stderr: '' }; + } + + return this.host.runCommand(this.descriptor.binary, finalArgs, { + ...runOptions, + cwd: executionDirectory, + stdio: 'pipe', + env: finalEnv, + }); + } + + /** + * A private, generic method to encapsulate the common logic of running a command, + * handling errors, and parsing the output. + * @param args The arguments to pass to the command. + * @param parser A function that parses the command's stdout. + * @param options Options for the command, including caching. + * @returns A promise that resolves to the parsed data, or null if not found. + */ + async #fetchAndParse( + args: readonly string[], + parser: (stdout: string, logger?: Logger) => T | null, + options: { + timeout?: number; + registry?: string; + bypassCache?: boolean; + cache?: Map; + cacheKey?: string; + } = {}, + ): Promise { + const { cache, cacheKey, bypassCache, ...runOptions } = options; + + if (!bypassCache && cache && cacheKey && cache.has(cacheKey)) { + return cache.get(cacheKey) as T | null; + } + + let stdout; + let stderr; + try { + ({ stdout, stderr } = await this.#run(args, runOptions)); + } catch (e) { + if (e instanceof PackageManagerError && typeof e.exitCode === 'number' && e.exitCode !== 0) { + // Some package managers exit with a non-zero code when the package is not found. + if (cache && cacheKey) { + cache.set(cacheKey, null); + } + + return null; + } + throw e; + } + + try { + const result = parser(stdout, this.options.logger); + if (cache && cacheKey) { + cache.set(cacheKey, result); + } + + return result; + } catch (e) { + const message = `Failed to parse package manager output: ${ + e instanceof Error ? e.message : '' + }`; + throw new PackageManagerError(message, stdout, stderr, 0); + } + } + + /** + * Adds a package to the project's dependencies. + * @param packageName The name of the package to add. + * @param save The save strategy to use. + * - `exact`: The package will be saved with an exact version. + * - `tilde`: The package will be saved with a tilde version range (`~`). + * - `none`: The package will be saved with the default version range (`^`). + * @param asDevDependency Whether to install the package as a dev dependency. + * @param noLockfile Whether to skip updating the lockfile. + * @param options Extra options for the command. + * @returns A promise that resolves when the command is complete. + */ + async add( + packageName: string, + save: 'exact' | 'tilde' | 'none', + asDevDependency: boolean, + noLockfile: boolean, + ignoreScripts: boolean, + options: { registry?: string } = {}, + ): Promise { + const flags = [ + asDevDependency ? this.descriptor.saveDevFlag : '', + save === 'exact' ? this.descriptor.saveExactFlag : '', + save === 'tilde' ? this.descriptor.saveTildeFlag : '', + noLockfile ? this.descriptor.noLockfileFlag : '', + ignoreScripts ? this.descriptor.ignoreScriptsFlag : '', + ].filter((flag) => flag); + + const args = [this.descriptor.addCommand, packageName, ...flags]; + await this.#run(args, options); + + this.#dependencyCache = null; + } + + /** + * Installs all dependencies in the project. + * @param options Options for the installation. + * @param options.timeout The maximum time in milliseconds to wait for the command to complete. + * @param options.force If true, forces a clean install, potentially overwriting existing modules. + * @param options.registry The registry to use for the installation. + * @param options.ignoreScripts If true, prevents lifecycle scripts from being executed. + * @returns A promise that resolves when the command is complete. + */ + async install( + options: { + timeout?: number; + force?: boolean; + registry?: string; + ignoreScripts?: boolean; + } = { ignoreScripts: true }, + ): Promise { + const flags = [ + options.force ? this.descriptor.forceFlag : '', + options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : '', + ].filter((flag) => flag); + const args = [...this.descriptor.installCommand, ...flags]; + + await this.#run(args, options); + + this.#dependencyCache = null; + } + + /** + * Gets the version of the package manager binary. + * @returns A promise that resolves to the trimmed version string. + */ + async getVersion(): Promise { + const { stdout } = await this.#run(this.descriptor.versionCommand); + + return stdout.trim(); + } + + /** + * Gets the installed details of a package from the project's dependencies. + * @param packageName The name of the package to check. + * @returns A promise that resolves to the installed package details, or `null` if the package is not installed. + */ + async getInstalledPackage(packageName: string): Promise { + const cache = await this.#populateDependencyCache(); + + return cache.get(packageName) ?? null; + } + + /** + * Gets a map of all top-level dependencies installed in the project. + * @returns A promise that resolves to a map of package names to their installed package details. + */ + async getProjectDependencies(): Promise> { + const cache = await this.#populateDependencyCache(); + + // Return a copy to prevent external mutations of the cache. + return new Map(cache); + } + + /** + * Fetches the registry metadata for a package. This is the full metadata, + * including all versions and distribution tags. + * @param packageName The name of the package to fetch the metadata for. + * @param options Options for the fetch. + * @param options.timeout The maximum time in milliseconds to wait for the command to complete. + * @param options.registry The registry to use for the fetch. + * @param options.bypassCache If true, ignores the in-memory cache and fetches fresh data. + * @returns A promise that resolves to the `PackageMetadata` object, or `null` if the package is not found. + */ + async getRegistryMetadata( + packageName: string, + options: { timeout?: number; registry?: string; bypassCache?: boolean } = {}, + ): Promise { + const commandArgs = [...this.descriptor.getManifestCommand, packageName]; + const formatter = this.descriptor.viewCommandFieldArgFormatter; + if (formatter) { + commandArgs.push(...formatter(METADATA_FIELDS)); + } + + const cacheKey = options.registry ? `${packageName}|${options.registry}` : packageName; + + return this.#fetchAndParse( + commandArgs, + (stdout, logger) => this.descriptor.outputParsers.getRegistryMetadata(stdout, logger), + { ...options, cache: this.#metadataCache, cacheKey: packageName }, + ); + } + + /** + * Fetches the registry manifest for a specific version of a package. + * The manifest is similar to the package's `package.json` file. + * @param packageName The name of the package to fetch the manifest for. + * @param version The version of the package to fetch the manifest for. + * @param options Options for the fetch. + * @param options.timeout The maximum time in milliseconds to wait for the command to complete. + * @param options.registry The registry to use for the fetch. + * @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( + packageName: string, + version: string, + options: { timeout?: number; registry?: string; bypassCache?: boolean } = {}, + ): Promise { + const specifier = `${packageName}@${version}`; + const commandArgs = [...this.descriptor.getManifestCommand, specifier]; + const formatter = this.descriptor.viewCommandFieldArgFormatter; + if (formatter) { + commandArgs.push(...formatter(MANIFEST_FIELDS)); + } + + const cacheKey = options.registry ? `${specifier}|${options.registry}` : specifier; + + return this.#fetchAndParse( + commandArgs, + (stdout, logger) => this.descriptor.outputParsers.getPackageManifest(stdout, logger), + { ...options, cache: this.#manifestCache, cacheKey: specifier }, + ); + } + + /** + * 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 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 } = {}, + ): Promise<{ workingDirectory: string; cleanup: () => Promise }> { + const workingDirectory = await this.host.createTempDirectory(); + const cleanup = () => this.host.deleteDirectory(workingDirectory); + + // Some package managers, like yarn classic, do not write a package.json when adding a package. + // This can cause issues with subsequent `require.resolve` calls. + // 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]; + + try { + await this.#run(args, { ...options, cwd: workingDirectory }); + } catch (e) { + // If the command fails, clean up the temporary directory immediately. + await cleanup(); + throw e; + } + + return { workingDirectory, cleanup }; + } +} diff --git a/packages/angular/cli/src/package-managers/package-metadata.ts b/packages/angular/cli/src/package-managers/package-metadata.ts new file mode 100644 index 000000000000..2bf3c7edb41c --- /dev/null +++ b/packages/angular/cli/src/package-managers/package-metadata.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * This file defines the core interfaces for package metadata and manifests, + * providing a strongly-typed representation of the data returned by a package + * manager registry. These interfaces are crucial for features like `ng add` + * and `ng update`. + */ + +/** + * Defines how a package's dependencies should be saved to `package.json` + * after being installed by the `ng add` command. + * + * - `dependencies`: Save to the `dependencies` section. + * - `devDependencies`: Save to the `devDependencies` section. + * - `false`: Do not save to `package.json`. + */ +export type NgAddSaveDependency = 'dependencies' | 'devDependencies' | false; + +/** + * Represents the configuration for `ng add` found in a package's manifest. + */ +export interface NgAdd { + /** + * Specifies how the package should be saved to `package.json`. + * @see NgAddSaveDependency + */ + save?: NgAddSaveDependency; +} + +/** + * Represents the configuration for `ng update` found in a package's manifest. + */ +export interface NgUpdate { + /** + * The path to the schematics collection for migrations. + */ + migrations?: string; + + /** + * A list of package names that should be updated together. + */ + packageGroup?: string[]; +} + +/** + * Represents the full metadata for a package available in the registry. + * This includes a list of all available versions and distribution tags. + */ +export interface PackageMetadata { + /** The name of the package. */ + name: string; + + /** A mapping of distribution tags (e.g., 'latest', 'next') to version numbers. */ + 'dist-tags': Record; + + /** An array of all available version strings for the package. */ + versions: string[]; + + /** A mapping of version numbers to their ISO 8601 publication time string. */ + time?: Record; +} + +/** + * Represents the manifest (similar to `package.json`) for a specific version of a package. + * It contains metadata essential for the Angular CLI to perform operations like + * `ng add` and `ng update`. + */ +export interface PackageManifest { + /** The name of the package. */ + name: string; + + /** The version of the package. */ + version: string; + + /** A mapping of production dependencies. */ + dependencies?: Record; + + /** A mapping of peer dependencies. */ + peerDependencies?: Record; + + /** A mapping of development dependencies. */ + devDependencies?: Record; + + /** The URL to the package's homepage. */ + homepage?: string; + + /** The path to the schematics collection definition, used by `ng generate`. */ + schematics?: string; + + /** Configuration for the `ng add` command. */ + 'ng-add'?: NgAdd; + + /** Configuration for the `ng update` command. */ + 'ng-update'?: NgUpdate; +} diff --git a/packages/angular/cli/src/package-managers/package-tree.ts b/packages/angular/cli/src/package-managers/package-tree.ts new file mode 100644 index 000000000000..ea438dcf8ce4 --- /dev/null +++ b/packages/angular/cli/src/package-managers/package-tree.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * This file defines the interfaces for representing a project's installed + * package dependency tree. + */ + +/** + * Represents a package that is installed in the project's node_modules. + */ +export interface InstalledPackage { + /** The name of the package. */ + readonly name: string; + + /** The installed version of the package. */ + readonly version: string; + + /** The absolute path to the package's directory on disk, if available. */ + readonly path?: string; +} diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts new file mode 100644 index 000000000000..e14b455a4fe6 --- /dev/null +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file contains the parser functions that are used to + * interpret the output of various package manager commands. Separating these + * into their own file improves modularity and allows for focused testing. + */ + +import { Logger } from './logger'; +import { PackageManifest, PackageMetadata } from './package-metadata'; +import { InstalledPackage } from './package-tree'; + +const MAX_LOG_LENGTH = 1024; + +function logStdout(stdout: string, logger?: Logger): void { + if (!logger) { + return; + } + + let output = stdout; + if (output.length > MAX_LOG_LENGTH) { + output = `${output.slice(0, MAX_LOG_LENGTH)}... (truncated)`; + } + + logger.debug(` stdout:\n${output}`); +} + +interface NpmListDependency { + version: string; + path?: string; + [key: string]: unknown; +} + +/** + * Parses the output of `npm list` or a compatible command. + * + * The expected JSON structure is: + * ```json + * { + * "dependencies": { + * "@angular/cli": { + * "version": "18.0.0", + * "path": "/path/to/project/node_modules/@angular/cli", // path is optional + * ... (other package.json properties) + * } + * } + * } + * ``` + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns A map of package names to their installed package details. + */ +export function parseNpmLikeDependencies( + stdout: string, + logger?: Logger, +): Map { + logger?.debug(`Parsing npm-like dependency list...`); + logStdout(stdout, logger); + + const dependencies = new Map(); + if (!stdout) { + logger?.debug(' stdout is empty. No dependencies found.'); + + return dependencies; + } + + let data = JSON.parse(stdout); + if (Array.isArray(data)) { + // pnpm returns an array of projects. + data = data[0]; + } + + const dependencyMaps = [data.dependencies, data.devDependencies, data.unsavedDependencies].filter( + (d) => !!d, + ); + + if (dependencyMaps.length === 0) { + logger?.debug(' `dependencies` property not found. No dependencies found.'); + + return dependencies; + } + + for (const dependencyMap of dependencyMaps) { + for (const [name, info] of Object.entries(dependencyMap as Record)) { + dependencies.set(name, { + name, + version: info.version, + path: info.path, + }); + } + } + + logger?.debug(` Found ${dependencies.size} dependencies.`); + + return dependencies; +} + +/** + * Parses the output of `yarn list` (classic). + * + * The expected output is a JSON stream (JSONL), where each line is a JSON object. + * The relevant object has a `type` of `'tree'`. + * Yarn classic does not provide a path, so the `path` property will be `undefined`. + * + * ```json + * {"type":"tree","data":{"trees":[{"name":"@angular/cli@18.0.0","children":[]}]}} + * ``` + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns A map of package names to their installed package details. + */ +export function parseYarnClassicDependencies( + stdout: string, + logger?: Logger, +): Map { + logger?.debug(`Parsing yarn classic dependency list...`); + logStdout(stdout, logger); + + const dependencies = new Map(); + if (!stdout) { + logger?.debug(' stdout is empty. No dependencies found.'); + + return dependencies; + } + + for (const line of stdout.split('\n')) { + if (!line) { + continue; + } + const json = JSON.parse(line); + if (json.type === 'tree' && json.data?.trees) { + for (const info of json.data.trees) { + const name = info.name.split('@')[0]; + const version = info.name.split('@').pop(); + dependencies.set(name, { + name, + version, + }); + } + } + } + + logger?.debug(` Found ${dependencies.size} dependencies.`); + + return dependencies; +} + +/** + * Parses the output of `yarn list` (modern). + * + * The expected JSON structure is a single object. + * Yarn modern does not provide a path, so the `path` property will be `undefined`. + * + * ```json + * { + * "trees": [ + * { "name": "@angular/cli@18.0.0", "children": [] } + * ] + * } + * ``` + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns A map of package names to their installed package details. + */ +export function parseYarnModernDependencies( + stdout: string, + logger?: Logger, +): Map { + logger?.debug(`Parsing yarn modern dependency list...`); + logStdout(stdout, logger); + + const dependencies = new Map(); + if (!stdout) { + logger?.debug(' stdout is empty. No dependencies found.'); + + return dependencies; + } + + // Modern yarn `list` command outputs a single JSON object with a `trees` property. + // Each line is not a separate JSON object. + try { + const data = JSON.parse(stdout); + for (const info of data.trees) { + const name = info.name.split('@')[0]; + const version = info.name.split('@').pop(); + dependencies.set(name, { + name, + version, + }); + } + } catch (e) { + logger?.debug( + ` Failed to parse as single JSON object: ${e}. Falling back to line-by-line parsing.`, + ); + // Fallback for older versions of yarn berry that might still output json lines + for (const line of stdout.split('\n')) { + if (!line) { + continue; + } + try { + const json = JSON.parse(line); + if (json.type === 'tree' && json.data?.trees) { + for (const info of json.data.trees) { + const name = info.name.split('@')[0]; + const version = info.name.split('@').pop(); + dependencies.set(name, { + name, + version, + }); + } + } + } catch (innerError) { + logger?.debug(` Ignoring non-JSON line: ${innerError}`); + // Ignore lines that are not valid JSON. + } + } + } + + logger?.debug(` Found ${dependencies.size} dependencies.`); + + return dependencies; +} + +/** + * Parses the output of `npm view` or a compatible command 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 parseNpmLikeManifest(stdout: string, logger?: Logger): PackageManifest | null { + logger?.debug(`Parsing npm-like manifest...`); + logStdout(stdout, logger); + + if (!stdout) { + logger?.debug(' stdout is empty. No manifest found.'); + + return null; + } + + return JSON.parse(stdout); +} + +/** + * Parses the output of `npm view` or a compatible command 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 parseNpmLikeMetadata(stdout: string, logger?: Logger): PackageMetadata | null { + logger?.debug(`Parsing npm-like metadata...`); + logStdout(stdout, logger); + + if (!stdout) { + logger?.debug(' stdout is empty. No metadata found.'); + + return null; + } + + return JSON.parse(stdout); +} + +/** + * Parses the output of `yarn info` (classic). + * @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 { + logger?.debug(`Parsing yarn classic manifest...`); + logStdout(stdout, logger); + + if (!stdout) { + logger?.debug(' stdout is empty. No manifest found.'); + + return null; + } + + const data = JSON.parse(stdout); + + // Yarn classic wraps the manifest in a `data` property. + return data.data ?? data; +} diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts new file mode 100644 index 000000000000..69b252501850 --- /dev/null +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Stats } from 'node:fs'; +import { Host } from '../host'; + +/** + * A mock `Host` implementation for testing. + * This class allows for simulating a file system in memory. + */ +export class MockHost implements Host { + private readonly fs = new Map(); + + constructor(files: Record = {}) { + // Normalize paths to use forward slashes for consistency in tests. + for (const [path, content] of Object.entries(files)) { + this.fs.set(path.replace(/\\/g, '/'), content); + } + } + + stat(path: string): Promise { + const content = this.fs.get(path.replace(/\\/g, '/')); + if (content === undefined) { + return Promise.reject(new Error(`File not found: ${path}`)); + } + + // A `true` value signifies a directory in our mock file system. + return Promise.resolve({ isDirectory: () => content === true } as Stats); + } + + readdir(path: string): Promise { + const content = this.fs.get(path.replace(/\\/g, '/')); + if (content === true || content === undefined) { + // This should be a directory with a file list. + return Promise.reject(new Error(`Directory not found or not a directory: ${path}`)); + } + + return Promise.resolve(content); + } + + runCommand(): Promise<{ stdout: string; stderr: string }> { + throw new Error('Method not implemented.'); + } + + createTempDirectory(): Promise { + throw new Error('Method not implemented.'); + } + + deleteDirectory(): Promise { + throw new Error('Method not implemented.'); + } + + writeFile(): Promise { + throw new Error('Method not implemented.'); + } +}