From 9145f5e78045e2f02ad248e4d7b90452e4d7aadc Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:22:59 +0000 Subject: [PATCH 1/2] refactor(@angular/cli): remove old package manager utilities This removes the old implementation of the package-manager. --- .../cli/src/command-builder/command-module.ts | 11 +- .../cli/src/command-builder/command-runner.ts | 84 ++++- .../cli/src/command-builder/definitions.ts | 4 +- packages/angular/cli/src/commands/add/cli.ts | 61 +--- .../angular/cli/src/commands/update/cli.ts | 15 +- .../angular/cli/src/package-managers/host.ts | 15 +- .../src/package-managers/package-manager.ts | 33 +- .../cli/src/utilities/package-manager.ts | 339 ------------------ 8 files changed, 142 insertions(+), 420 deletions(-) delete mode 100644 packages/angular/cli/src/utilities/package-manager.ts diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index ff30cf976b7b..b02c0dffda9b 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import { logging, schema } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; import { readFileSync } from 'node:fs'; -import * as path from 'node:path'; +import { join, posix } from 'node:path'; import type { ArgumentsCamelCase, Argv, CommandModule as YargsCommandModule } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; import { getAnalyticsUserId } from '../analytics/analytics'; @@ -17,7 +17,6 @@ import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics- import { considerSettingUpAutocompletion } from '../utilities/completion'; import { AngularWorkspace } from '../utilities/config'; import { memoize } from '../utilities/memoize'; -import { PackageManagerUtils } from '../utilities/package-manager'; import { CommandContext, CommandScope, Options, OtherOptions } from './definitions'; import { Option, addSchemaOptionsToCommand } from './utilities/json-schema'; @@ -75,8 +74,8 @@ export abstract class CommandModule implements CommandModuleI ...(this.longDescriptionPath ? { longDescriptionRelativePath: path - .relative(path.join(__dirname, '../../../../'), this.longDescriptionPath) - .replace(/\\/g, path.posix.sep), + .relative(join(__dirname, '../../../../'), this.longDescriptionPath) + .replace(/\\/g, posix.sep), longDescription: readFileSync(this.longDescriptionPath, 'utf8').replace( /\r\n/g, '\n', @@ -156,7 +155,7 @@ export abstract class CommandModule implements CommandModuleI return userId ? new AnalyticsCollector(this.context.logger, userId, { name: this.context.packageManager.name, - version: this.context.packageManager.version, + version: await this.context.packageManager.getVersion(), }) : undefined; } diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index cb4ab2c8467e..a78ee228d7eb 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -6,19 +6,22 @@ * found in the LICENSE file at https://angular.dev/license */ -import { logging } from '@angular-devkit/core'; +import { JsonValue, isJsonObject, logging } from '@angular-devkit/core'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import yargs from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; +import { getCacheConfig } from '../commands/cache/utilities'; import { CommandConfig, CommandNames, RootCommands, RootCommandsAliases, } from '../commands/command-config'; +import { PackageManagerName, createPackageManager } from '../package-managers'; import { colors } from '../utilities/color'; -import { AngularWorkspace, getWorkspace } from '../utilities/config'; +import { AngularWorkspace, getProjectByCwd, getWorkspace } from '../utilities/config'; import { assertIsError } from '../utilities/error'; -import { PackageManagerUtils } from '../utilities/package-manager'; import { VERSION } from '../utilities/version'; import { CommandContext, CommandModuleError } from './command-module'; import { @@ -34,11 +37,12 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis $0, _, help = false, + dryRun = false, jsonHelp = false, getYargsCompletions = false, ...rest } = yargsParser(args, { - boolean: ['help', 'json-help', 'get-yargs-completions'], + boolean: ['help', 'json-help', 'get-yargs-completions', 'dry-run'], alias: { 'collection': 'c' }, }); @@ -60,8 +64,21 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis } const root = workspace?.basePath ?? process.cwd(); - const localYargs = yargs(args); + const cacheConfig = getCacheConfig(workspace); + const packageManager = await createPackageManager({ + cwd: root, + logger, + dryRun, + tempDirectory: cacheConfig.enabled ? cacheConfig.path : undefined, + configuredPackageManager: await getConfiguredPackageManager( + root, + workspace, + globalConfiguration, + ), + }); + + const localYargs = yargs(args); const context: CommandContext = { globalConfiguration, workspace, @@ -69,7 +86,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis currentDirectory: process.cwd(), yargsInstance: localYargs, root, - packageManager: new PackageManagerUtils({ globalConfiguration, workspace, root }), + packageManager, args: { positional: positional.map((v) => v.toString()), options: { @@ -163,3 +180,58 @@ async function getCommandsToRegister( return Promise.all(commands.map((command) => command.factory().then((m) => m.default))); } + +/** + * Gets the configured package manager by checking package.json, or the local and global angular.json files. + * + * @param root The root directory of the workspace. + * @param localWorkspace The local workspace. + * @param globalWorkspace The global workspace. + * @returns The package manager name. + */ +async function getConfiguredPackageManager( + root: string, + localWorkspace: AngularWorkspace | undefined, + globalWorkspace: AngularWorkspace, +): Promise { + let result: PackageManagerName | undefined; + + try { + const packageJsonPath = join(root, 'package.json'); + const pkgJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as JsonValue; + result = getPackageManager(pkgJson); + } catch {} + + if (result) { + return result; + } + + if (localWorkspace) { + const project = getProjectByCwd(localWorkspace); + if (project) { + result = getPackageManager(localWorkspace.projects.get(project)?.extensions['cli']); + } + + result ??= getPackageManager(localWorkspace.extensions['cli']); + } + + result ??= getPackageManager(globalWorkspace.extensions['cli']); + + return result; +} + +/** + * Get the package manager name from a JSON value. + * @param source The JSON value to get the package manager name from. + * @returns The package manager name. + */ +function getPackageManager(source: JsonValue | undefined): PackageManagerName | undefined { + if (source && isJsonObject(source)) { + const value = source['packageManager']; + if (typeof value === 'string') { + return value.split('@', 1)[0] as unknown as PackageManagerName; + } + } + + return undefined; +} diff --git a/packages/angular/cli/src/command-builder/definitions.ts b/packages/angular/cli/src/command-builder/definitions.ts index 8bfc8f4a4d51..479379a76cd9 100644 --- a/packages/angular/cli/src/command-builder/definitions.ts +++ b/packages/angular/cli/src/command-builder/definitions.ts @@ -8,8 +8,8 @@ import { logging } from '@angular-devkit/core'; import type { Argv, CamelCaseKey } from 'yargs'; +import { PackageManager } from '../package-managers/package-manager'; import { AngularWorkspace } from '../utilities/config'; -import { PackageManagerUtils } from '../utilities/package-manager'; export enum CommandScope { /** Command can only run inside an Angular workspace. */ @@ -28,7 +28,7 @@ export interface CommandContext { workspace?: AngularWorkspace; globalConfiguration: AngularWorkspace; logger: logging.Logger; - packageManager: PackageManagerUtils; + packageManager: PackageManager; yargsInstance: Argv<{}>; /** Arguments parsed in free-from without parser configuration. */ diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 0531604d63d1..a27c6405f18f 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -10,7 +10,7 @@ import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2'; import assert from 'node:assert'; import fs from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, join } from 'node:path'; import npa from 'npm-package-arg'; import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; import { Argv } from 'yargs'; @@ -25,16 +25,13 @@ import { } from '../../command-builder/schematics-command-module'; import { NgAddSaveDependency, - PackageManager, PackageManagerError, PackageManifest, PackageMetadata, - createPackageManager, } from '../../package-managers'; import { assertIsError } from '../../utilities/error'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; -import { getCacheConfig } from '../cache/utilities'; class CommandError extends Error {} @@ -46,7 +43,6 @@ interface AddCommandArgs extends SchematicsCommandArgs { } interface AddCommandTaskContext { - packageManager: PackageManager; packageIdentifier: npa.Result; savePackage?: NgAddSaveDependency; collectionName?: string; @@ -198,7 +194,8 @@ export default class AddCommandModule [ { title: 'Determining Package Manager', - task: (context, task) => this.determinePackageManagerTask(context, task), + task: (_context, task) => + (task.output = `Using package manager: ${color.dim(this.context.packageManager.name)}`), rendererOptions: { persistentOutput: true }, }, { @@ -309,47 +306,14 @@ export default class AddCommandModule } } - private async determinePackageManagerTask( - context: AddCommandTaskContext, - task: AddCommandTaskWrapper, - ): Promise { - let tempDirectory: string | undefined; - const tempOptions = ['node_modules']; - - const cacheConfig = getCacheConfig(this.context.workspace); - if (cacheConfig.enabled) { - const cachePath = resolve(this.context.root, cacheConfig.path); - if (!relative(this.context.root, cachePath).startsWith('..')) { - tempOptions.push(cachePath); - } - } - - for (const tempOption of tempOptions) { - try { - const directory = resolve(this.context.root, tempOption); - if ((await fs.stat(directory)).isDirectory()) { - tempDirectory = directory; - break; - } - } catch {} - } - - context.packageManager = await createPackageManager({ - cwd: this.context.root, - logger: this.context.logger, - dryRun: context.dryRun, - tempDirectory, - }); - task.output = `Using package manager: ${color.dim(context.packageManager.name)}`; - } - private async findCompatiblePackageVersionTask( context: AddCommandTaskContext, task: AddCommandTaskWrapper, options: Options, ): Promise { const { registry, verbose } = options; - const { packageManager, packageIdentifier } = context; + const { packageIdentifier } = context; + const { packageManager } = this.context; const packageName = packageIdentifier.name; assert(packageName, 'Registry package identifiers should always have a name.'); @@ -446,7 +410,8 @@ export default class AddCommandModule rejectionReasons: string[]; }, ): Promise { - const { packageManager, packageIdentifier } = context; + const { packageIdentifier } = context; + const { packageManager } = this.context; const { registry, verbose, rejectionReasons } = options; const packageName = packageIdentifier.name; assert(packageName, 'Package name must be defined.'); @@ -524,9 +489,12 @@ export default class AddCommandModule let manifest; try { - manifest = await context.packageManager.getManifest(context.packageIdentifier.toString(), { - registry, - }); + manifest = await this.context.packageManager.getManifest( + context.packageIdentifier.toString(), + { + registry, + }, + ); } catch (e) { assertIsError(e); throw new CommandError( @@ -585,7 +553,8 @@ export default class AddCommandModule options: Options, ): Promise { const { registry } = options; - const { packageManager, packageIdentifier, savePackage } = context; + const { packageIdentifier, savePackage } = context; + const { packageManager } = this.context; // Only show if installation will actually occur task.title = 'Installing package'; diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 8703eb017f24..e5e298675c3f 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -20,12 +20,7 @@ import { Options, } from '../../command-builder/command-module'; import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; -import { - InstalledPackage, - PackageManager, - PackageManifest, - createPackageManager, -} from '../../package-managers'; +import { InstalledPackage, PackageManager, PackageManifest } from '../../package-managers'; import { colors } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; @@ -168,13 +163,7 @@ export default class UpdateCommandModule extends CommandModule): Promise { - const { logger } = this.context; - // Instantiate the package manager - const packageManager = await createPackageManager({ - cwd: this.context.root, - logger, - configuredPackageManager: this.context.packageManager.name, - }); + const { logger, packageManager } = this.context; // Check if the current installed CLI version is older than the latest compatible version. // Skip when running `ng update` without a package name as this will not trigger an actual update. diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 4c8744fd8781..e137f87eef61 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -14,8 +14,8 @@ */ import { type SpawnOptions, spawn } from 'node:child_process'; -import { Stats, constants } from 'node:fs'; -import { copyFile, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { Stats, constants, existsSync } from 'node:fs'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { platform, tmpdir } from 'node:os'; import { join } from 'node:path'; import { PackageManagerError } from './error'; @@ -24,6 +24,13 @@ import { PackageManagerError } from './error'; * An abstraction layer for side-effectful operations. */ export interface Host { + /** + * Creates a directory. + * @param path The path to the directory. + * @returns A promise that resolves when the directory is created. + */ + mkdir(path: string): Promise; + /** * Gets the stats of a file or directory. * @param path The path to the file or directory. @@ -101,10 +108,12 @@ export interface Host { export const NodeJS_HOST: Host = { stat, readdir, + mkdir, readFile: (path: string) => readFile(path, { encoding: 'utf8' }), copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE), writeFile, - createTempDirectory: (baseDir?: string) => mkdtemp(join(baseDir ?? tmpdir(), 'angular-cli-')), + createTempDirectory: (baseDir?: string) => + mkdtemp(join(baseDir ?? tmpdir(), 'angular-cli-tmp-packages-')), deleteDirectory: (path: string) => rm(path, { recursive: true, force: true }), runCommand: async ( command: string, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 45c9639d954b..3145dcff4c4a 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -12,7 +12,7 @@ * a flexible and secure abstraction over the various package managers. */ -import { join } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import npa from 'npm-package-arg'; import { maxSatisfying } from 'semver'; import { PackageManagerError } from './error'; @@ -61,8 +61,7 @@ export interface PackageManagerOptions { logger?: Logger; /** - * The path to use as the base for temporary directories. - * If not specified, the system's temporary directory will be used. + * The base path to use for temporary directories. */ tempDirectory?: string; @@ -340,7 +339,6 @@ export class PackageManager { /** * Gets the version of the package manager binary. - * @returns A promise that resolves to the trimmed version string. */ async getVersion(): Promise { if (this.#version) { @@ -544,6 +542,29 @@ export class PackageManager { } } + private async getTemporaryDirectory(): Promise { + const { tempDirectory } = this.options; + + if (tempDirectory && !relative(this.cwd, tempDirectory).startsWith('..')) { + try { + await this.host.stat(tempDirectory); + } catch { + // If the cache directory doesn't exist, create it. + await this.host.mkdir(tempDirectory); + } + } + + const tempOptions = ['node_modules']; + for (const tempOption of tempOptions) { + try { + const directory = resolve(this.cwd, tempOption); + if ((await this.host.stat(directory)).isDirectory()) { + return directory; + } + } catch {} + } + } + /** * Acquires a package by installing it into a temporary directory. The caller is * responsible for managing the lifecycle of the temporary directory by calling @@ -558,7 +579,9 @@ export class PackageManager { specifier: string, options: { registry?: string; ignoreScripts?: boolean } = {}, ): Promise<{ workingDirectory: string; cleanup: () => Promise }> { - const workingDirectory = await this.host.createTempDirectory(this.options.tempDirectory); + const workingDirectory = await this.host.createTempDirectory( + await this.getTemporaryDirectory(), + ); const cleanup = () => this.host.deleteDirectory(workingDirectory); // Some package managers, like yarn classic, do not write a package.json when adding a package. diff --git a/packages/angular/cli/src/utilities/package-manager.ts b/packages/angular/cli/src/utilities/package-manager.ts deleted file mode 100644 index b913a3bfd72d..000000000000 --- a/packages/angular/cli/src/utilities/package-manager.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * @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 { JsonValue, isJsonObject } from '@angular-devkit/core'; -import { execSync, spawn } from 'node:child_process'; -import { promises as fs, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { PackageManager } from '../../lib/config/workspace-schema'; -import { AngularWorkspace, getProjectByCwd } from './config'; -import { memoize } from './memoize'; - -/** - * A map of package managers to their corresponding lockfile names. - */ -const LOCKFILE_NAMES: Readonly> = { - [PackageManager.Yarn]: 'yarn.lock', - [PackageManager.Pnpm]: 'pnpm-lock.yaml', - [PackageManager.Bun]: ['bun.lockb', 'bun.lock'], - [PackageManager.Npm]: 'package-lock.json', -}; - -interface PackageManagerOptions { - saveDev: string; - install: string; - installAll?: string; - prefix: string; - noLockfile: string; -} - -export interface PackageManagerUtilsContext { - globalConfiguration: AngularWorkspace; - workspace?: AngularWorkspace; - root: string; -} - -/** - * Utilities for interacting with various package managers. - */ -export class PackageManagerUtils { - /** - * @param context The context for the package manager utilities, including workspace and global configuration. - */ - constructor(private readonly context: PackageManagerUtilsContext) {} - - /** Get the package manager name. */ - get name(): PackageManager { - return this.getName(); - } - - /** Get the package manager version. */ - get version(): string | undefined { - return this.getVersion(this.name); - } - - /** Install a single package. */ - async install( - packageName: string, - save: 'dependencies' | 'devDependencies' | boolean = true, - extraArgs: string[] = [], - cwd?: string, - ): Promise { - const packageManagerArgs = this.getArguments(); - const installArgs: string[] = [packageManagerArgs.install, packageName]; - - if (save === 'devDependencies') { - installArgs.push(packageManagerArgs.saveDev); - } else if (save === false) { - installArgs.push(packageManagerArgs.noLockfile); - } - - return this.run([...installArgs, ...extraArgs], { cwd, silent: true }); - } - - /** Install all packages. */ - async installAll(extraArgs: string[] = [], cwd?: string): Promise { - const packageManagerArgs = this.getArguments(); - const installArgs: string[] = []; - if (packageManagerArgs.installAll) { - installArgs.push(packageManagerArgs.installAll); - } - - return this.run([...installArgs, ...extraArgs], { cwd, silent: true }); - } - - /** Install a single package temporary. */ - async installTemp( - packageName: string, - extraArgs?: string[], - ): Promise<{ - success: boolean; - tempNodeModules: string; - }> { - const tempPath = await fs.mkdtemp(join(realpathSync(tmpdir()), 'angular-cli-packages-')); - - // clean up temp directory on process exit - process.on('exit', () => { - try { - rmSync(tempPath, { recursive: true, maxRetries: 3 }); - } catch {} - }); - - // NPM will warn when a `package.json` is not found in the install directory - // Example: - // npm WARN enoent ENOENT: no such file or directory, open '/tmp/.ng-temp-packages-84Qi7y/package.json' - // npm WARN .ng-temp-packages-84Qi7y No description - // npm WARN .ng-temp-packages-84Qi7y No repository field. - // npm WARN .ng-temp-packages-84Qi7y No license field. - - // While we can use `npm init -y` we will end up needing to update the 'package.json' anyways - // because of missing fields. - await fs.writeFile( - join(tempPath, 'package.json'), - JSON.stringify({ - name: 'temp-cli-install', - description: 'temp-cli-install', - repository: 'temp-cli-install', - license: 'MIT', - }), - ); - - // setup prefix/global modules path - const packageManagerArgs = this.getArguments(); - const tempNodeModules = join(tempPath, 'node_modules'); - // Yarn will not append 'node_modules' to the path - const prefixPath = this.name === PackageManager.Yarn ? tempNodeModules : tempPath; - const installArgs: string[] = [ - ...(extraArgs ?? []), - `${packageManagerArgs.prefix}="${prefixPath}"`, - packageManagerArgs.noLockfile, - ]; - - return { - success: await this.install(packageName, true, installArgs, tempPath), - tempNodeModules, - }; - } - - private getArguments(): PackageManagerOptions { - switch (this.name) { - case PackageManager.Yarn: - return { - saveDev: '--dev', - install: 'add', - prefix: '--modules-folder', - noLockfile: '--no-lockfile', - }; - case PackageManager.Pnpm: - return { - saveDev: '--save-dev', - install: 'add', - installAll: 'install', - prefix: '--prefix', - noLockfile: '--no-lockfile', - }; - case PackageManager.Bun: - return { - saveDev: '--dev', - install: 'add', - installAll: 'install', - prefix: '--cwd', - noLockfile: '--no-save', - }; - default: - return { - saveDev: '--save-dev', - install: 'install', - installAll: 'install', - prefix: '--prefix', - noLockfile: '--no-package-lock', - }; - } - } - - private async run( - args: string[], - options: { cwd?: string; silent?: boolean } = {}, - ): Promise { - const { cwd = process.cwd(), silent = false } = options; - - return new Promise((resolve) => { - const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; - - const childProcess = spawn(`${this.name} ${args.join(' ')}`, { - // Always pipe stderr to allow for failures to be reported - stdio: silent ? ['ignore', 'ignore', 'pipe'] : 'pipe', - shell: true, - cwd, - }).on('close', (code: number) => { - if (code === 0) { - resolve(true); - } else { - bufferedOutput.forEach(({ stream, data }) => stream.write(data)); - resolve(false); - } - }); - - childProcess.stdout?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stdout, data: data }), - ); - childProcess.stderr?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stderr, data: data }), - ); - }); - } - - @memoize - private getVersion(name: PackageManager): string | undefined { - try { - return execSync(`${name} --version`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - env: { - ...process.env, - // NPM updater notifier will prevents the child process from closing until it timeout after 3 minutes. - NO_UPDATE_NOTIFIER: '1', - NPM_CONFIG_UPDATE_NOTIFIER: 'false', - }, - }).trim(); - } catch { - return undefined; - } - } - - @memoize - private getName(): PackageManager { - const packageManager = this.getConfiguredPackageManager(); - if (packageManager) { - return packageManager; - } - - const filesInRoot = readdirSync(this.context.root); - const hasNpmLock = this.hasLockfile(PackageManager.Npm, filesInRoot); - const hasYarnLock = this.hasLockfile(PackageManager.Yarn, filesInRoot); - const hasPnpmLock = this.hasLockfile(PackageManager.Pnpm, filesInRoot); - const hasBunLock = this.hasLockfile(PackageManager.Bun, filesInRoot); - - // PERF NOTE: `this.getVersion` spawns the package a the child_process which can take around ~300ms at times. - // Therefore, we should only call this method when needed. IE: don't call `this.getVersion(PackageManager.Pnpm)` unless truly needed. - // The result of this method is not stored in a variable because it's memoized. - - if (hasNpmLock) { - // Has NPM lock file. - if (!hasYarnLock && !hasPnpmLock && !hasBunLock && this.getVersion(PackageManager.Npm)) { - // Only NPM lock file and NPM binary is available. - return PackageManager.Npm; - } - } else { - // No NPM lock file. - if (hasYarnLock && this.getVersion(PackageManager.Yarn)) { - // Yarn lock file and Yarn binary is available. - return PackageManager.Yarn; - } else if (hasPnpmLock && this.getVersion(PackageManager.Pnpm)) { - // PNPM lock file and PNPM binary is available. - return PackageManager.Pnpm; - } else if (hasBunLock && this.getVersion(PackageManager.Bun)) { - // Bun lock file and Bun binary is available. - return PackageManager.Bun; - } - } - - if (!this.getVersion(PackageManager.Npm)) { - // Doesn't have NPM installed. - const hasYarn = !!this.getVersion(PackageManager.Yarn); - const hasPnpm = !!this.getVersion(PackageManager.Pnpm); - const hasBun = !!this.getVersion(PackageManager.Bun); - - if (hasYarn && !hasPnpm && !hasBun) { - return PackageManager.Yarn; - } else if (hasPnpm && !hasYarn && !hasBun) { - return PackageManager.Pnpm; - } else if (hasBun && !hasYarn && !hasPnpm) { - return PackageManager.Bun; - } - } - - // TODO: This should eventually inform the user of ambiguous package manager usage. - // Potentially with a prompt to choose and optionally set as the default. - return PackageManager.Npm; - } - - /** - * Checks if a lockfile for a specific package manager exists in the root directory. - * @param packageManager The package manager to check for. - * @param filesInRoot An array of file names in the root directory. - * @returns True if the lockfile exists, false otherwise. - */ - private hasLockfile(packageManager: PackageManager, filesInRoot: string[]): boolean { - const lockfiles = LOCKFILE_NAMES[packageManager]; - - return typeof lockfiles === 'string' - ? filesInRoot.includes(lockfiles) - : lockfiles.some((lockfile) => filesInRoot.includes(lockfile)); - } - - private getConfiguredPackageManager(): PackageManager | undefined { - const { workspace: localWorkspace, globalConfiguration: globalWorkspace } = this.context; - let result: PackageManager | undefined; - - try { - const packageJsonPath = join(this.context.root, 'package.json'); - const pkgJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as JsonValue; - result = getPackageManager(pkgJson); - } catch {} - - if (result) { - return result; - } - - if (localWorkspace) { - const project = getProjectByCwd(localWorkspace); - if (project) { - result = getPackageManager(localWorkspace.projects.get(project)?.extensions['cli']); - } - - result ??= getPackageManager(localWorkspace.extensions['cli']); - } - - result ??= getPackageManager(globalWorkspace.extensions['cli']); - - return result; - } -} - -function getPackageManager(source: JsonValue | undefined): PackageManager | undefined { - if (source && isJsonObject(source)) { - const value = source['packageManager']; - if (typeof value === 'string') { - return value.split('@', 1)[0] as PackageManager; - } - } - - return undefined; -} From 6f8ebb7400a65ea11404a0175cda059f2269ebe4 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:06:42 +0000 Subject: [PATCH 2/2] fixup! refactor(@angular/cli): remove old package manager utilities --- .../cli/src/command-builder/command-module.ts | 9 +++---- .../cli/src/command-builder/command-runner.ts | 24 ++++++++++--------- .../cli/src/command-builder/definitions.ts | 2 +- .../angular/cli/src/commands/update/cli.ts | 2 +- .../commands/update/utilities/cli-version.ts | 2 +- .../angular/cli/src/commands/version/cli.ts | 2 +- .../cli/src/commands/version/version-info.ts | 9 ++++--- .../cli/src/package-managers/factory.ts | 17 +++++++------ .../angular/cli/src/package-managers/host.ts | 5 ++-- .../src/package-managers/package-manager.ts | 2 +- .../src/package-managers/testing/mock-host.ts | 4 ++++ 11 files changed, 44 insertions(+), 34 deletions(-) diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index b02c0dffda9b..e5cc6f70473a 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -8,7 +8,7 @@ import { schema } from '@angular-devkit/core'; import { readFileSync } from 'node:fs'; -import { join, posix } from 'node:path'; +import { join, posix, relative } from 'node:path'; import type { ArgumentsCamelCase, Argv, CommandModule as YargsCommandModule } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; import { getAnalyticsUserId } from '../analytics/analytics'; @@ -73,9 +73,10 @@ export abstract class CommandModule implements CommandModuleI describe: this.describe, ...(this.longDescriptionPath ? { - longDescriptionRelativePath: path - .relative(join(__dirname, '../../../../'), this.longDescriptionPath) - .replace(/\\/g, posix.sep), + longDescriptionRelativePath: relative( + join(__dirname, '../../../../'), + this.longDescriptionPath, + ).replace(/\\/g, posix.sep), longDescription: readFileSync(this.longDescriptionPath, 'utf8').replace( /\r\n/g, '\n', diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index a78ee228d7eb..452f9afe8f68 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -18,7 +18,8 @@ import { RootCommands, RootCommandsAliases, } from '../commands/command-config'; -import { PackageManagerName, createPackageManager } from '../package-managers'; +import { createPackageManager } from '../package-managers'; +import { ConfiguredPackageManagerInfo } from '../package-managers/factory'; import { colors } from '../utilities/color'; import { AngularWorkspace, getProjectByCwd, getWorkspace } from '../utilities/config'; import { assertIsError } from '../utilities/error'; @@ -64,13 +65,12 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis } const root = workspace?.basePath ?? process.cwd(); - - const cacheConfig = getCacheConfig(workspace); + const cacheConfig = workspace && getCacheConfig(workspace); const packageManager = await createPackageManager({ cwd: root, logger, - dryRun, - tempDirectory: cacheConfig.enabled ? cacheConfig.path : undefined, + dryRun: dryRun || help || jsonHelp || getYargsCompletions, + tempDirectory: cacheConfig?.enabled ? cacheConfig.path : undefined, configuredPackageManager: await getConfiguredPackageManager( root, workspace, @@ -187,14 +187,14 @@ async function getCommandsToRegister( * @param root The root directory of the workspace. * @param localWorkspace The local workspace. * @param globalWorkspace The global workspace. - * @returns The package manager name. + * @returns The package manager name and version. */ async function getConfiguredPackageManager( root: string, localWorkspace: AngularWorkspace | undefined, globalWorkspace: AngularWorkspace, -): Promise { - let result: PackageManagerName | undefined; +): Promise { + let result: ConfiguredPackageManagerInfo | undefined; try { const packageJsonPath = join(root, 'package.json'); @@ -223,13 +223,15 @@ async function getConfiguredPackageManager( /** * Get the package manager name from a JSON value. * @param source The JSON value to get the package manager name from. - * @returns The package manager name. + * @returns The package manager name and version. */ -function getPackageManager(source: JsonValue | undefined): PackageManagerName | undefined { +function getPackageManager( + source: JsonValue | undefined, +): ConfiguredPackageManagerInfo | undefined { if (source && isJsonObject(source)) { const value = source['packageManager']; if (typeof value === 'string') { - return value.split('@', 1)[0] as unknown as PackageManagerName; + return value.split('@', 2) as unknown as ConfiguredPackageManagerInfo; } } diff --git a/packages/angular/cli/src/command-builder/definitions.ts b/packages/angular/cli/src/command-builder/definitions.ts index 479379a76cd9..d552b432b685 100644 --- a/packages/angular/cli/src/command-builder/definitions.ts +++ b/packages/angular/cli/src/command-builder/definitions.ts @@ -8,7 +8,7 @@ import { logging } from '@angular-devkit/core'; import type { Argv, CamelCaseKey } from 'yargs'; -import { PackageManager } from '../package-managers/package-manager'; +import type { PackageManager } from '../package-managers/package-manager'; import { AngularWorkspace } from '../utilities/config'; export enum CommandScope { diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index e5e298675c3f..9f990845b59b 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -20,7 +20,7 @@ import { Options, } from '../../command-builder/command-module'; import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; -import { InstalledPackage, PackageManager, PackageManifest } from '../../package-managers'; +import type { InstalledPackage, PackageManager, PackageManifest } from '../../package-managers'; import { colors } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; diff --git a/packages/angular/cli/src/commands/update/utilities/cli-version.ts b/packages/angular/cli/src/commands/update/utilities/cli-version.ts index 15e9a0ef32a8..6067b81504d4 100644 --- a/packages/angular/cli/src/commands/update/utilities/cli-version.ts +++ b/packages/angular/cli/src/commands/update/utilities/cli-version.ts @@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, promises as fs } from 'node:fs'; import { join, resolve } from 'node:path'; import * as semver from 'semver'; -import { PackageManager } from '../../../package-managers'; +import type { PackageManager } from '../../../package-managers'; import { VERSION } from '../../../utilities/version'; import { ANGULAR_PACKAGES_REGEXP } from './constants'; diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts index 7dfb138aa382..205f0bc7e55e 100644 --- a/packages/angular/cli/src/commands/version/cli.ts +++ b/packages/angular/cli/src/commands/version/cli.ts @@ -56,7 +56,7 @@ export default class VersionCommandModule */ async run(options: { json?: boolean }): Promise { const { logger } = this.context; - const versionInfo = gatherVersionInfo(this.context); + const versionInfo = await gatherVersionInfo(this.context); if (options.json) { // eslint-disable-next-line no-console diff --git a/packages/angular/cli/src/commands/version/version-info.ts b/packages/angular/cli/src/commands/version/version-info.ts index 850cc18d0947..3e75c2c58cac 100644 --- a/packages/angular/cli/src/commands/version/version-info.ts +++ b/packages/angular/cli/src/commands/version/version-info.ts @@ -7,6 +7,8 @@ */ import { createRequire } from 'node:module'; +import { CommandContext } from '../../command-builder/definitions'; +import { PackageManager } from '../../package-managers'; import { VERSION } from '../../utilities/version'; /** @@ -81,10 +83,7 @@ const PACKAGE_PATTERNS = [ * Gathers all the version information from the environment and workspace. * @returns An object containing all the version information. */ -export function gatherVersionInfo(context: { - packageManager: { name: string; version: string | undefined }; - root: string; -}): VersionInfo { +export async function gatherVersionInfo(context: CommandContext): Promise { // Trailing slash is used to allow the path to be treated as a directory const workspaceRequire = createRequire(context.root + '/'); @@ -132,7 +131,7 @@ export function gatherVersionInfo(context: { }, packageManager: { name: context.packageManager.name, - version: context.packageManager.version, + version: await context.packageManager.getVersion(), }, }, packages, diff --git a/packages/angular/cli/src/package-managers/factory.ts b/packages/angular/cli/src/package-managers/factory.ts index 790a48140285..e3635ae7b30f 100644 --- a/packages/angular/cli/src/package-managers/factory.ts +++ b/packages/angular/cli/src/package-managers/factory.ts @@ -14,6 +14,11 @@ import { Logger } from './logger'; import { PackageManager } from './package-manager'; import { PackageManagerName, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; +/** + * Information about the package manager to use for a given project. + */ +export type ConfiguredPackageManagerInfo = [name?: PackageManagerName, version?: string]; + /** * The default package manager to use when none is discovered or configured. */ @@ -59,7 +64,7 @@ async function getPackageManagerVersion( async function determinePackageManager( host: Host, cwd: string, - configured?: PackageManagerName, + configured: ConfiguredPackageManagerInfo = [], logger?: Logger, dryRun?: boolean, ): Promise<{ @@ -67,11 +72,10 @@ async function determinePackageManager( source: 'configured' | 'discovered' | 'default'; version?: string; }> { - let name: PackageManagerName; + let [name, version] = configured; let source: 'configured' | 'discovered' | 'default'; - if (configured) { - name = configured; + if (name) { source = 'configured'; logger?.debug(`Using configured package manager: '${name}'.`); } else { @@ -89,7 +93,6 @@ async function determinePackageManager( } } - let version: string | undefined; if (name === 'yarn' && !dryRun) { assert.deepStrictEqual( SUPPORTED_PACKAGE_MANAGERS.yarn.versionCommand, @@ -98,7 +101,7 @@ async function determinePackageManager( ); try { - version = await getPackageManagerVersion(host, cwd, name, logger); + version ??= await getPackageManagerVersion(host, cwd, name, logger); if (version && major(version) < 2) { name = 'yarn-classic'; logger?.debug(`Detected yarn classic. Using 'yarn-classic'.`); @@ -124,7 +127,7 @@ async function determinePackageManager( */ export async function createPackageManager(options: { cwd: string; - configuredPackageManager?: PackageManagerName; + configuredPackageManager?: ConfiguredPackageManagerInfo; logger?: Logger; dryRun?: boolean; tempDirectory?: string; diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index e137f87eef61..f7509ff01a99 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -14,7 +14,7 @@ */ import { type SpawnOptions, spawn } from 'node:child_process'; -import { Stats, constants, existsSync } from 'node:fs'; +import { Stats, constants } from 'node:fs'; import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { platform, tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,9 +27,10 @@ export interface Host { /** * Creates a directory. * @param path The path to the directory. + * @param options Options for the directory creation. * @returns A promise that resolves when the directory is created. */ - mkdir(path: string): Promise; + mkdir(path: string, options?: { recursive?: boolean }): Promise; /** * Gets the stats of a file or directory. diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 3145dcff4c4a..7a1dfc7f934b 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -550,7 +550,7 @@ export class PackageManager { await this.host.stat(tempDirectory); } catch { // If the cache directory doesn't exist, create it. - await this.host.mkdir(tempDirectory); + await this.host.mkdir(tempDirectory, { recursive: true }); } } 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 2411c8917318..0a22266467a0 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -23,6 +23,10 @@ export class MockHost implements Host { } } + mkdir(path: string, options?: { recursive?: boolean }): Promise { + throw new Error('Method not implemented.'); + } + stat(path: string): Promise { const content = this.fs.get(path.replace(/\\/g, '/')); if (content === undefined) {