UNPKG

@angular/cli

Version:
449 lines • 21.4 kB
"use strict"; /** * @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 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PackageManager = void 0; /** * @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. */ const node_path_1 = require("node:path"); const npm_package_arg_1 = __importDefault(require("npm-package-arg")); const semver_1 = require("semver"); const error_1 = require("./error"); /** * 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']; /** * 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', 'deprecated', 'dependencies', 'peerDependencies', 'devDependencies', 'homepage', 'schematics', 'ng-add', 'ng-update', ]; /** * 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. */ class PackageManager { host; cwd; descriptor; options; #manifestCache = new Map(); #metadataCache = new Map(); #dependencyCache = 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(host, cwd, descriptor, options = {}) { this.host = host; this.cwd = cwd; this.descriptor = descriptor; this.options = options; 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() { 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() { 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, options = {}) { const { registry, cwd, ...runOptions } = options; const finalArgs = [...args]; let finalEnv; 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: '' }; } const commandResult = await this.host.runCommand(this.descriptor.binary, finalArgs, { ...runOptions, cwd: executionDirectory, stdio: 'pipe', env: finalEnv, }); return { stdout: commandResult.stdout.trim(), stderr: commandResult.stderr.trim() }; } /** * 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, parser, options = {}) { const { cache, cacheKey, bypassCache, ...runOptions } = options; if (!bypassCache && cache && cacheKey && cache.has(cacheKey)) { return cache.get(cacheKey); } let stdout; let stderr; let exitCode; let thrownError; try { ({ stdout, stderr } = await this.#run(args, runOptions)); exitCode = 0; } catch (e) { thrownError = e; if (e instanceof error_1.PackageManagerError) { stdout = e.stdout; stderr = e.stderr; exitCode = e.exitCode; } else { // Re-throw unexpected errors throw e; } } // Yarn classic can exit with code 0 even when an error occurs. // To ensure we capture these cases, we will always attempt to parse a // structured error from the output, regardless of the exit code. const getError = this.descriptor.outputParsers.getError; const parsedError = getError?.(stdout, this.options.logger) ?? getError?.(stderr, this.options.logger) ?? null; if (parsedError) { this.options.logger?.debug(`[${this.descriptor.binary}] Structured error (code: ${parsedError.code}): ${parsedError.summary}`); // Special case for 'not found' errors (e.g., E404). Return null for these. if (this.descriptor.isNotFound(parsedError)) { if (cache && cacheKey) { cache.set(cacheKey, null); } return null; } else { // For all other structured errors, throw a more informative error. throw new error_1.PackageManagerError(parsedError.summary, stdout, stderr, exitCode); } } // If an error was originally thrown and we didn't parse a more specific // structured error, re-throw the original error now. if (thrownError) { throw thrownError; } // If we reach this point, the command succeeded and no structured error was found. // We can now safely parse the successful output. 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 error_1.PackageManagerError(message, stdout, stderr, exitCode); } } /** * 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, save, asDevDependency, noLockfile, ignoreScripts, options = {}) { 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 = { ignoreScripts: true }) { const flags = [ options.force ? this.descriptor.forceFlag : '', options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : '', options.ignorePeerDependencies ? (this.descriptor.ignorePeerDependenciesFlag ?? '') : '', ].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() { 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) { 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() { 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, options = {}) { 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 }); } /** * 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 getRegistryManifest(packageName, version, options = {}) { 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; const manifest = await this.#fetchAndParse(commandArgs, (stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger), { ...options, cache: this.#manifestCache, cacheKey }); // 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); } return manifest; } /** * 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, options = {}) { const { name, type, fetchSpec } = typeof specifier === 'string' ? (0, npm_package_arg_1.default)(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. let versionSpec = fetchSpec ?? 'latest'; if (this.descriptor.requiresManifestVersionLookup) { if (type === 'tag' || !fetchSpec) { const metadata = await this.getRegistryMetadata(name, options); if (!metadata) { return null; } versionSpec = metadata['dist-tags'][versionSpec]; } else if (type === 'range') { const metadata = await this.getRegistryMetadata(name, options); if (!metadata) { return null; } versionSpec = (0, semver_1.maxSatisfying)(metadata.versions, fetchSpec) ?? ''; } if (!versionSpec) { return null; } } return this.getRegistryManifest(name, versionSpec, options); } case 'directory': { if (!fetchSpec) { throw new Error(`Could not parse directory path from specifier: ${specifier}`); } const manifestPath = (0, node_path_1.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((0, node_path_1.join)(workingDirectory, 'package.json')); const { dependencies } = JSON.parse(tempManifest); 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 = (0, node_path_1.join)(workingDirectory, 'node_modules', packageName); const manifestPath = (0, node_path_1.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 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(specifier, options = {}) { const workingDirectory = await this.host.createTempDirectory(this.options.tempDirectory); 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((0, node_path_1.join)(workingDirectory, 'package.json'), '{}'); // Copy configuration files if the package manager requires it (e.g., bun). if (this.descriptor.copyConfigFromProject) { for (const configFile of this.descriptor.configFiles) { try { const configPath = (0, node_path_1.join)(this.cwd, configFile); await this.host.copyFile(configPath, (0, node_path_1.join)(workingDirectory, configFile)); } catch { // Ignore missing config files. } } } const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter((flag) => flag); const args = [this.descriptor.addCommand, specifier, ...flags]; 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 }; } } exports.PackageManager = PackageManager; //# sourceMappingURL=package-manager.js.map