Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 65 additions & 30 deletions packages/angular/cli/src/commands/add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import npa from 'npm-package-arg';
import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver';
import { Argv } from 'yargs';
Expand All @@ -26,13 +26,15 @@ import {
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 {}

Expand Down Expand Up @@ -298,10 +300,32 @@ export default class AddCommandModule
context: AddCommandTaskContext,
task: AddCommandTaskWrapper,
): Promise<void> {
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)}`;
}
Expand Down Expand Up @@ -553,36 +577,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;
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/angular/cli/src/package-managers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ export async function createPackageManager(options: {
configuredPackageManager?: PackageManagerName;
logger?: Logger;
dryRun?: boolean;
tempDirectory?: string;
}): Promise<PackageManager> {
const { cwd, configuredPackageManager, logger, dryRun } = options;
const { cwd, configuredPackageManager, logger, dryRun, tempDirectory } = options;
const host = NodeJS_HOST;

const { name, source } = await determinePackageManager(
Expand All @@ -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) {
Expand Down
18 changes: 14 additions & 4 deletions packages/angular/cli/src/package-managers/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
*/

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 { Stats, constants } from 'node:fs';
import { copyFile, 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';
Expand Down Expand Up @@ -45,11 +45,20 @@ export interface Host {
*/
readFile(path: string): Promise<string>;

/**
* Copies a file from the source path to the destination path.
* @param src The path to the source file.
* @param dest The path to the destination file.
* @returns A promise that resolves when the copy is complete.
*/
copyFile(src: string, dest: string): Promise<void>;

/**
* 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<string>;
createTempDirectory(baseDir?: string): Promise<string>;

/**
* Deletes a directory recursively.
Expand Down Expand Up @@ -93,8 +102,9 @@ export const NodeJS_HOST: Host = {
stat,
readdir,
readFile: (path: string) => readFile(path, { encoding: 'utf8' }),
copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE),
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,
Expand Down
1 change: 1 addition & 0 deletions packages/angular/cli/src/package-managers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export interface PackageManagerDescriptor {
/** The flag to ignore peer dependency warnings/errors. */
readonly ignorePeerDependenciesFlag?: string;

/** The configuration files used by the package manager. */
readonly configFiles: readonly string[];

/**
* Whether to copy configuration files from the project root to the temporary directory.
* This is necessary for package managers that do not inherit configuration from parent directories (e.g., bun).
*/
readonly copyConfigFromProject?: boolean;

/** A function that returns the arguments and environment variables to use a custom registry. */
readonly getRegistryOptions?: (registry: string) => {
args?: string[];
Expand Down Expand Up @@ -144,6 +153,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
noLockfileFlag: '--no-package-lock',
ignoreScriptsFlag: '--ignore-scripts',
ignorePeerDependenciesFlag: '--force',
configFiles: ['.npmrc'],
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json=true', '--all=true'],
Expand All @@ -168,6 +178,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
saveDevFlag: '--dev',
noLockfileFlag: '',
ignoreScriptsFlag: '--mode=skip-build',
configFiles: ['.yarnrc.yml', '.yarnrc.yaml'],
getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json', '--recursive=false'],
Expand Down Expand Up @@ -195,6 +206,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
saveDevFlag: '--dev',
noLockfileFlag: '--no-lockfile',
ignoreScriptsFlag: '--ignore-scripts',
configFiles: ['.yarnrc', '.npmrc'],
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json'],
Expand All @@ -220,6 +232,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
noLockfileFlag: '--no-lockfile',
ignoreScriptsFlag: '--ignore-scripts',
ignorePeerDependenciesFlag: '--strict-peer-dependencies=false',
configFiles: ['.npmrc', 'pnpm-workspace.yaml'],
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json'],
Expand All @@ -244,6 +257,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
saveDevFlag: '--development',
noLockfileFlag: '', // Bun does not have a flag for this.
ignoreScriptsFlag: '--ignore-scripts',
configFiles: ['bunfig.toml', '.npmrc'],
copyConfigFromProject: true,
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['pm', 'ls', '--json'],
Expand Down
20 changes: 19 additions & 1 deletion packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -538,14 +544,26 @@ export class PackageManager {
specifier: string,
options: { registry?: string; ignoreScripts?: boolean } = {},
): Promise<{ workingDirectory: string; cleanup: () => Promise<void> }> {
const workingDirectory = await this.host.createTempDirectory();
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(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 = join(this.cwd, configFile);
await this.host.copyFile(configPath, join(workingDirectory, configFile));
} catch {
// Ignore missing config files.
}
}
}

const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter(
(flag) => flag,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ export class MockHost implements Host {
readFile(): Promise<string> {
throw new Error('Method not implemented.');
}

copyFile(): Promise<void> {
throw new Error('Method not implemented.');
}
}