From 5adc74205ae8ab184684efcc2998366a5f55e37c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:24:55 +0000 Subject: [PATCH] fix(@angular/cli): use dedicated cache directory for temporary package installs Relocates temporary package installations to a dedicated cache directory. This ensures that the project's `.npmrc` is correctly respected, as the installer does relies on the current working directory (CWD) rather than flags like `--prefix`. --- packages/angular/cli/src/commands/add/cli.ts | 4 +- .../angular/cli/src/commands/update/cli.ts | 4 +- .../cli/src/package-managers/factory.ts | 9 +- .../angular/cli/src/package-managers/host.ts | 155 ++++++++++-------- .../src/package-managers/package-manager.ts | 8 +- tests/e2e/utils/registry.ts | 29 ++-- 6 files changed, 117 insertions(+), 92 deletions(-) diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 0dae016fba12..a5fba05c725d 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -33,6 +33,7 @@ import { import { assertIsError } from '../../utilities/error'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; +import { getCacheConfig } from '../cache/utilities'; class CommandError extends Error {} @@ -299,7 +300,8 @@ export default class AddCommandModule task: AddCommandTaskWrapper, ): Promise { context.packageManager = await createPackageManager({ - cwd: this.context.root, + cacheDirectory: getCacheConfig(this.context.workspace).path, + root: this.context.root, logger: this.context.logger, dryRun: context.dryRun, }); diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 9b926cc079a2..581b4de97bc8 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -30,6 +30,7 @@ import { getProjectDependencies, readPackageJson, } from '../../utilities/package-tree'; +import { getCacheConfig } from '../cache/utilities'; import { checkCLIVersion, coerceVersionNumber, @@ -172,7 +173,8 @@ export default class UpdateCommandModule extends CommandModule { - const { cwd, configuredPackageManager, logger, dryRun } = options; - const host = NodeJS_HOST; + const { root: cwd, cacheDirectory, configuredPackageManager, logger, dryRun } = options; + const host = createNodeJsHost(cwd, cacheDirectory); const { name, source } = await determinePackageManager( host, diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 82d61031d147..d078aa494df6 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -14,9 +14,9 @@ */ import { type SpawnOptions, spawn } from 'node:child_process'; -import { Stats } from 'node:fs'; -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; -import { platform, tmpdir } from 'node:os'; +import { Stats, existsSync } from 'node:fs'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { platform } from 'node:os'; import { join } from 'node:path'; import { PackageManagerError } from './error'; @@ -87,68 +87,95 @@ export interface Host { } /** - * A concrete implementation of the `Host` interface that uses the Node.js APIs. + * The package manager configuration files that are copied to the temp directory. */ -export const NodeJS_HOST: Host = { - stat, - readdir, - readFile: (path: string) => readFile(path, { encoding: 'utf8' }), - writeFile, - createTempDirectory: () => mkdtemp(join(tmpdir(), 'angular-cli-')), - deleteDirectory: (path: string) => rm(path, { recursive: true, force: true }), - 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; - const isWin32 = platform() === 'win32'; - - return new Promise((resolve, reject) => { - const spawnOptions = { - shell: isWin32, - stdio: options.stdio ?? 'pipe', - signal, - cwd: options.cwd, - env: { - ...process.env, - ...options.env, - }, - } satisfies SpawnOptions; - const childProcess = isWin32 - ? spawn(`${command} ${args.join(' ')}`, spawnOptions) - : spawn(command, args, spawnOptions); - - 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)); - } - }); +const PACKAGE_MANAGER_CONFIG_FILES = ['.npmrc', 'bunfig.toml']; - childProcess.on('error', (err) => { - if (err.name === 'AbortError') { - const message = `Process timed out.`; +/** + * A concrete implementation of the `Host` interface that uses the Node.js APIs. + * @param root The root directory of the project. + * @param cacheDirectory The directory to use for caching. + * @returns A host that uses the Node.js APIs. + */ +export function createNodeJsHost(root: string, cacheDirectory: string): Host { + return { + stat, + readdir, + readFile: (path: string) => readFile(path, { encoding: 'utf8' }), + writeFile, + createTempDirectory: async () => { + await mkdir(cacheDirectory, { recursive: true }); + const tmpDir = await mkdtemp(join(cacheDirectory, 'package-manager-tmp-')); + + // Copy the configs as bun does not read files up the tree. + await Promise.all( + PACKAGE_MANAGER_CONFIG_FILES.map((configFile) => { + const sourcePath = join(root, configFile); + const destinationPath = join(tmpDir, configFile); + + if (existsSync(sourcePath)) { + return copyFile(sourcePath, destinationPath); + } + }), + ); + + return tmpDir; + }, + 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; + const isWin32 = platform() === 'win32'; + + return new Promise((resolve, reject) => { + const spawnOptions = { + shell: isWin32, + stdio: options.stdio ?? 'pipe', + signal, + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + } satisfies SpawnOptions; + const childProcess = isWin32 + ? spawn(`${command} ${args.join(' ')}`, spawnOptions) + : spawn(command, args, spawnOptions); + + 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)); - - 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/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 57b521615273..aaa35c3bf3b2 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -546,9 +546,11 @@ export class PackageManager { // Writing an empty package.json file beforehand prevents this. await this.host.writeFile(join(workingDirectory, 'package.json'), '{}'); - const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter( - (flag) => flag, - ); + const flags = []; + if (options.ignoreScripts) { + flags.push(this.descriptor.ignoreScriptsFlag); + } + const args: readonly string[] = [this.descriptor.addCommand, specifier, ...flags]; try { diff --git a/tests/e2e/utils/registry.ts b/tests/e2e/utils/registry.ts index fd557c116120..f54ca4066b94 100644 --- a/tests/e2e/utils/registry.ts +++ b/tests/e2e/utils/registry.ts @@ -70,30 +70,21 @@ export async function createNpmConfigForAuthentication( const token = invalidToken ? `invalid=` : VALID_TOKEN; const registry = (getGlobalVariable('package-secure-registry') as string).replace(/^\w+:/, ''); + // `always-auth is required for yarn classic. + // See: https://www.verdaccio.org/docs/setup-yarn#yarn-classic-1x await writeFile( '.npmrc', scopedAuthentication ? ` -${registry}/:_auth="${token}" -registry=http:${registry} -` + ${registry}/:_auth="${token}" + registry=http:${registry} + always-auth = true + ` : ` -_auth="${token}" -registry=http:${registry} -`, - ); - - await writeFile( - '.yarnrc', - scopedAuthentication - ? ` -${registry}/:_auth "${token}" -registry http:${registry} -` - : ` -_auth "${token}" -registry http:${registry} -`, + _auth="${token}" + registry=http:${registry} + always-auth = true + `, ); }