diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 0dae016fba12..8157a067903b 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -26,6 +26,7 @@ import { import { NgAddSaveDependency, PackageManager, + PackageManagerError, PackageManifest, PackageMetadata, createPackageManager, @@ -298,10 +299,22 @@ export default class AddCommandModule context: AddCommandTaskContext, task: AddCommandTaskWrapper, ): Promise { + let tempDirectory: string | undefined; + for (const path of ['.angular/cache', 'node_modules']) { + try { + const directory = join(this.context.root, path); + 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)}`; } @@ -553,36 +566,47 @@ export default class AddCommandModule // Only show if installation will actually occur task.title = 'Installing package'; - if (context.savePackage === false) { - task.title += ' in temporary location'; - - // Temporary packages are located in a different directory - // Hence we need to resolve them using the temp path - const { workingDirectory } = await packageManager.acquireTempPackage( - packageIdentifier.toString(), - { - registry, - }, - ); - - const tempRequire = createRequire(workingDirectory + '/'); - assert(context.collectionName, 'Collection name should always be available'); - const resolvedCollectionPath = tempRequire.resolve( - join(context.collectionName, 'package.json'), - ); + try { + if (context.savePackage === false) { + task.title += ' in temporary location'; + + // Temporary packages are located in a different directory + // Hence we need to resolve them using the temp path + const { workingDirectory } = await packageManager.acquireTempPackage( + packageIdentifier.toString(), + { + registry, + }, + ); + + const tempRequire = createRequire(workingDirectory + '/'); + assert(context.collectionName, 'Collection name should always be available'); + const resolvedCollectionPath = tempRequire.resolve( + join(context.collectionName, 'package.json'), + ); + + context.collectionName = dirname(resolvedCollectionPath); + } else { + await packageManager.add( + packageIdentifier.toString(), + 'none', + savePackage !== 'dependencies', + false, + true, + { + registry, + }, + ); + } + } catch (e) { + if (e instanceof PackageManagerError) { + const output = e.stderr || e.stdout; + if (output) { + throw new CommandError(`Package installation failed: ${e.message}\nOutput: ${output}`); + } + } - context.collectionName = dirname(resolvedCollectionPath); - } else { - await packageManager.add( - packageIdentifier.toString(), - 'none', - savePackage !== 'dependencies', - false, - true, - { - registry, - }, - ); + throw e; } } diff --git a/packages/angular/cli/src/package-managers/factory.ts b/packages/angular/cli/src/package-managers/factory.ts index 19ec32f7f886..1cd3d2462edc 100644 --- a/packages/angular/cli/src/package-managers/factory.ts +++ b/packages/angular/cli/src/package-managers/factory.ts @@ -110,8 +110,9 @@ export async function createPackageManager(options: { configuredPackageManager?: PackageManagerName; logger?: Logger; dryRun?: boolean; + tempDirectory?: string; }): Promise { - const { cwd, configuredPackageManager, logger, dryRun } = options; + const { cwd, configuredPackageManager, logger, dryRun, tempDirectory } = options; const host = NodeJS_HOST; const { name, source } = await determinePackageManager( @@ -127,7 +128,11 @@ export async function createPackageManager(options: { throw new Error(`Unsupported package manager: "${name}"`); } - const packageManager = new PackageManager(host, cwd, descriptor, { dryRun, logger }); + const packageManager = new PackageManager(host, cwd, descriptor, { + dryRun, + logger, + tempDirectory, + }); // Do not verify if the package manager is installed during a dry run. if (!dryRun) { diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 82d61031d147..e063a97de03d 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -47,9 +47,10 @@ export interface Host { /** * Creates a new, unique temporary directory. + * @param baseDir The base directory in which to create the temporary directory. * @returns A promise that resolves to the absolute path of the created directory. */ - createTempDirectory(): Promise; + createTempDirectory(baseDir?: string): Promise; /** * Deletes a directory recursively. @@ -94,7 +95,7 @@ export const NodeJS_HOST: Host = { readdir, readFile: (path: string) => readFile(path, { encoding: 'utf8' }), writeFile, - createTempDirectory: () => mkdtemp(join(tmpdir(), 'angular-cli-')), + createTempDirectory: (baseDir?: string) => mkdtemp(join(baseDir ?? tmpdir(), 'angular-cli-')), deleteDirectory: (path: string) => rm(path, { recursive: true, force: true }), runCommand: async ( command: string, diff --git a/packages/angular/cli/src/package-managers/index.ts b/packages/angular/cli/src/package-managers/index.ts index 002ade0cdb01..c622539fec2f 100644 --- a/packages/angular/cli/src/package-managers/index.ts +++ b/packages/angular/cli/src/package-managers/index.ts @@ -9,5 +9,6 @@ export { createPackageManager } from './factory'; export type { PackageManagerName } from './package-manager-descriptor'; export { PackageManager } from './package-manager'; +export { PackageManagerError } from './error'; export type * from './package-metadata'; export type { InstalledPackage } from './package-tree'; diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index 631d444db93d..4692ad1389db 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -65,6 +65,16 @@ export interface PackageManagerDescriptor { /** The flag to ignore peer dependency warnings/errors. */ readonly ignorePeerDependenciesFlag?: string; + /** + * The strategy to use when acquiring a temporary package. + * - `temporary-directory`: The package is installed in a separate temporary directory. (Default) + * - `project-root`: The package is installed in the project root (e.g. `node_modules`), but not saved to `package.json`. + */ + readonly tempPackageStrategy?: 'temporary-directory' | 'project-root'; + + /** The flag to install a package without saving it to `package.json`. Used with `project-root` strategy. */ + readonly noSaveFlag?: string; + /** A function that returns the arguments and environment variables to use a custom registry. */ readonly getRegistryOptions?: (registry: string) => { args?: string[]; @@ -141,6 +151,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { saveExactFlag: '--save-exact', saveTildeFlag: '--save-tilde', saveDevFlag: '--save-dev', + noSaveFlag: '--no-save', noLockfileFlag: '--no-package-lock', ignoreScriptsFlag: '--ignore-scripts', ignorePeerDependenciesFlag: '--force', @@ -242,6 +253,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = { saveExactFlag: '--exact', saveTildeFlag: '', // Bun does not have a flag for tilde, it defaults to caret. saveDevFlag: '--development', + noSaveFlag: '--no-save', + tempPackageStrategy: 'project-root', noLockfileFlag: '', // Bun does not have a flag for this. ignoreScriptsFlag: '--ignore-scripts', getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 57b521615273..0d1e443dfddf 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -59,6 +59,12 @@ export interface PackageManagerOptions { /** A logger instance for debugging and dry run output. */ logger?: Logger; + + /** + * The path to use as the base for temporary directories. + * If not specified, the system's temporary directory will be used. + */ + tempDirectory?: string; } /** @@ -538,7 +544,19 @@ export class PackageManager { specifier: string, options: { registry?: string; ignoreScripts?: boolean } = {}, ): Promise<{ workingDirectory: string; cleanup: () => Promise }> { - const workingDirectory = await this.host.createTempDirectory(); + if (this.descriptor.tempPackageStrategy === 'project-root') { + const flags = [ + options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : '', + this.descriptor.noSaveFlag, + ].filter((flag): flag is string => !!flag); + const args = [this.descriptor.addCommand, specifier, ...flags]; + + await this.#run(args, { ...options, cwd: this.cwd }); + + return { workingDirectory: this.cwd, cleanup: async () => {} }; + } + + 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.