Skip to content

Commit 7081cce

Browse files
committed
refactor(@angular/cli): introduce new package manager abstraction
Introduces a new abstraction layer for interacting with JavaScript package managers. This change centralizes all package-manager-specific logic into a single, reliable, and extensible interface. The primary motivation is to standardize package manager interactions for commands like `ng add` and `ng update`. The new system is designed to be highly testable. All side-effectful operations (file system access, command execution) are abstracted behind a `Host` interface, allowing for comprehensive and reliable unit testing in complete isolation. Key features include: - A high-level API to install, add, and query packages from a registry. - Methods to fetch full package metadata and specific version manifests. - An `acquireTempPackage` method to support temporary, isolated package installations. This abstraction paves the way for the eventual removal of several direct package dependencies, including `ini`, `@yarnpkg/lockfile`, and `pacote`.
1 parent 54a2ca3 commit 7081cce

12 files changed

Lines changed: 1580 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* @fileoverview This file contains the logic for discovering the package manager
11+
* used in a project by searching for lockfiles. It is designed to be efficient
12+
* and to correctly handle monorepo structures.
13+
*/
14+
15+
import { dirname, join } from 'node:path';
16+
import { Host } from './host';
17+
import { Logger } from './logger';
18+
import {
19+
PACKAGE_MANAGER_PRECEDENCE,
20+
PackageManagerName,
21+
SUPPORTED_PACKAGE_MANAGERS,
22+
} from './package-manager-descriptor';
23+
24+
/**
25+
* A map from lockfile names to their corresponding package manager.
26+
* This is a performance optimization to avoid iterating over all possible
27+
* lockfiles in every directory.
28+
*/
29+
const LOCKFILE_TO_PACKAGE_MANAGER = new Map<string, PackageManagerName>();
30+
for (const [name, descriptor] of Object.entries(SUPPORTED_PACKAGE_MANAGERS)) {
31+
for (const lockfile of descriptor.lockfiles) {
32+
LOCKFILE_TO_PACKAGE_MANAGER.set(lockfile, name as PackageManagerName);
33+
}
34+
}
35+
36+
/**
37+
* Searches a directory for lockfiles and returns a set of package managers that correspond to them.
38+
* @param host A `Host` instance for interacting with the file system.
39+
* @param directory The directory to search.
40+
* @param logger An optional logger instance.
41+
* @returns A promise that resolves to a set of package manager names.
42+
*/
43+
async function findLockfiles(
44+
host: Host,
45+
directory: string,
46+
logger?: Logger,
47+
): Promise<Set<PackageManagerName>> {
48+
logger?.debug(`Searching for lockfiles in '${directory}'...`);
49+
50+
try {
51+
const files = await host.readdir(directory);
52+
const foundPackageManagers = new Set<PackageManagerName>();
53+
54+
for (const file of files) {
55+
const packageManager = LOCKFILE_TO_PACKAGE_MANAGER.get(file);
56+
if (packageManager) {
57+
logger?.debug(` Found '${file}'.`);
58+
foundPackageManagers.add(packageManager);
59+
}
60+
}
61+
62+
return foundPackageManagers;
63+
} catch (e) {
64+
logger?.debug(` Failed to read directory: ${e}`);
65+
66+
// Ignore directories that don't exist or can't be read.
67+
return new Set();
68+
}
69+
}
70+
71+
/**
72+
* Checks if a given path is a directory.
73+
* @param host A `Host` instance for interacting with the file system.
74+
* @param path The path to check.
75+
* @returns A promise that resolves to true if the path is a directory, false otherwise.
76+
*/
77+
async function isDirectory(host: Host, path: string): Promise<boolean> {
78+
try {
79+
return (await host.stat(path)).isDirectory();
80+
} catch {
81+
return false;
82+
}
83+
}
84+
85+
/**
86+
* Discovers the package manager used in a project by searching for lockfiles.
87+
*
88+
* This function searches for lockfiles in the given directory and its ancestors.
89+
* If multiple lockfiles are found, it uses the precedence array to determine
90+
* which package manager to use. The search is bounded by the git repository root.
91+
*
92+
* @param host A `Host` instance for interacting with the file system.
93+
* @param startDir The directory to start the search from.
94+
* @param logger An optional logger instance.
95+
* @returns A promise that resolves to the name of the discovered package manager, or null if none is found.
96+
*/
97+
export async function discover(
98+
host: Host,
99+
startDir: string,
100+
logger?: Logger,
101+
): Promise<PackageManagerName | null> {
102+
logger?.debug(`Starting package manager discovery in '${startDir}'...`);
103+
let currentDir = startDir;
104+
105+
while (true) {
106+
const found = await findLockfiles(host, currentDir, logger);
107+
108+
if (found.size > 0) {
109+
logger?.debug(`Found lockfile(s): [${[...found].join(', ')}]. Applying precedence...`);
110+
for (const packageManager of PACKAGE_MANAGER_PRECEDENCE) {
111+
if (found.has(packageManager)) {
112+
logger?.debug(`Selected '${packageManager}' based on precedence.`);
113+
114+
return packageManager;
115+
}
116+
}
117+
}
118+
119+
// Stop searching if we reach the git repository root.
120+
if (await isDirectory(host, join(currentDir, '.git'))) {
121+
logger?.debug(`Reached repository root at '${currentDir}'. Stopping search.`);
122+
123+
return null;
124+
}
125+
126+
const parentDir = dirname(currentDir);
127+
if (parentDir === currentDir) {
128+
// We have reached the filesystem root.
129+
logger?.debug('Reached filesystem root. No lockfile found.');
130+
131+
return null;
132+
}
133+
134+
currentDir = parentDir;
135+
}
136+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { discover } from './discovery';
10+
import { MockHost } from './testing/mock-host';
11+
12+
describe('discover', () => {
13+
it('should find a lockfile in the starting directory', async () => {
14+
const host = new MockHost({
15+
'/project': ['package-lock.json'],
16+
});
17+
const result = await discover(host, '/project');
18+
expect(result).toBe('npm');
19+
});
20+
21+
it('should find a lockfile in a parent directory', async () => {
22+
const host = new MockHost({
23+
'/project': ['yarn.lock'],
24+
'/project/subdir': [],
25+
});
26+
const result = await discover(host, '/project/subdir');
27+
expect(result).toBe('yarn');
28+
});
29+
30+
it('should return null if no lockfile is found up to the root', async () => {
31+
const host = new MockHost({
32+
'/': [],
33+
'/project': [],
34+
'/project/subdir': [],
35+
});
36+
const result = await discover(host, '/project/subdir');
37+
expect(result).toBeNull();
38+
});
39+
40+
it('should apply precedence when multiple lockfiles are found', async () => {
41+
const host = new MockHost({
42+
'/project': ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'],
43+
});
44+
// pnpm should have the highest precedence according to the descriptor.
45+
const result = await discover(host, '/project');
46+
expect(result).toBe('pnpm');
47+
});
48+
49+
it('should stop searching at a .git boundary', async () => {
50+
const host = new MockHost({
51+
'/': ['yarn.lock'],
52+
'/project': ['.git'], // .git is mocked as a directory.
53+
'/project/subdir': [],
54+
});
55+
const result = await discover(host, '/project/subdir');
56+
expect(result).toBeNull();
57+
});
58+
59+
it('should stop searching at the filesystem root', async () => {
60+
const host = new MockHost({
61+
'/': [],
62+
});
63+
const result = await discover(host, '/');
64+
expect(result).toBeNull();
65+
});
66+
67+
it('should handle file system errors during readdir gracefully', async () => {
68+
const host = new MockHost({});
69+
host.readdir = () => Promise.reject(new Error('Permission denied'));
70+
71+
const result = await discover(host, '/project');
72+
expect(result).toBeNull();
73+
});
74+
75+
it('should handle file system errors during stat gracefully', async () => {
76+
const host = new MockHost({ '/project': ['.git'] });
77+
host.stat = () => Promise.reject(new Error('Permission denied'));
78+
79+
// The error on stat should prevent it from finding the .git dir and thus it will continue to the root.
80+
const result = await discover(host, '/project');
81+
expect(result).toBeNull();
82+
});
83+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* @fileoverview This file defines a custom error class for the package manager
11+
* abstraction. This allows for structured error handling and provides consumers
12+
* with detailed information about the process failure.
13+
*/
14+
15+
/**
16+
* A custom error class for package manager-related errors.
17+
*
18+
* This error class provides structured data about the failed process,
19+
* including stdout, stderr, and the exit code.
20+
*/
21+
export class PackageManagerError extends Error {
22+
/**
23+
* Creates a new `PackageManagerError` instance.
24+
* @param message The error message.
25+
* @param stdout The standard output of the failed process.
26+
* @param stderr The standard error of the failed process.
27+
* @param exitCode The exit code of the failed process.
28+
*/
29+
constructor(
30+
message: string,
31+
public readonly stdout: string,
32+
public readonly stderr: string,
33+
public readonly exitCode: number | null,
34+
) {
35+
super(message);
36+
}
37+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { major } from 'semver';
10+
import { discover } from './discovery';
11+
import { Host, NodeJS_HOST } from './host';
12+
import { Logger } from './logger';
13+
import { PackageManager } from './package-manager';
14+
import { PackageManagerName, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor';
15+
import { getYarnVersion, isPackageManagerInstalled } from './verification';
16+
17+
/**
18+
* Determines the package manager to use for a given project.
19+
*
20+
* This function will determine the package manager by checking for a configured
21+
* package manager, discovering the package manager from lockfiles, or falling
22+
* back to a default. It also handles differentiation between yarn classic and modern.
23+
*
24+
* @param host A `Host` instance for interacting with the file system and running commands.
25+
* @param cwd The directory to start the search from.
26+
* @param configured An optional, explicitly configured package manager.
27+
* @param logger An optional logger instance.
28+
* @returns A promise that resolves to an object containing the name and source of the package manager.
29+
*/
30+
async function determinePackageManager(
31+
host: Host,
32+
cwd: string,
33+
configured?: PackageManagerName,
34+
logger?: Logger,
35+
): Promise<{ name: PackageManagerName; source: 'configured' | 'discovered' | 'default' }> {
36+
let name: PackageManagerName;
37+
let source: 'configured' | 'discovered' | 'default';
38+
39+
if (configured) {
40+
name = configured;
41+
source = 'configured';
42+
logger?.debug(`Using configured package manager: '${name}'.`);
43+
} else {
44+
const discovered = await discover(host, cwd, logger);
45+
if (discovered) {
46+
name = discovered;
47+
source = 'discovered';
48+
logger?.debug(`Discovered package manager: '${name}'.`);
49+
} else {
50+
name = 'npm';
51+
source = 'default';
52+
logger?.debug(`No lockfile found. Using default package manager: 'npm'.`);
53+
}
54+
}
55+
56+
if (name === 'yarn') {
57+
const version = await getYarnVersion(host, logger);
58+
if (version && major(version) < 2) {
59+
name = 'yarn-legacy';
60+
logger?.debug(`Detected yarn classic. Using 'yarn-legacy'.`);
61+
}
62+
}
63+
64+
return { name, source };
65+
}
66+
67+
/**
68+
* Creates a new `PackageManager` instance for a given project.
69+
*
70+
* This function is the main entry point for the package manager abstraction.
71+
* It will determine, verify, and instantiate the correct package manager.
72+
*
73+
* @param options An object containing the options for creating the package manager.
74+
* @returns A promise that resolves to a new `PackageManager` instance.
75+
*/
76+
export async function createPackageManager(options: {
77+
cwd: string;
78+
configuredPackageManager?: PackageManagerName;
79+
logger?: Logger;
80+
dryRun?: boolean;
81+
}): Promise<PackageManager> {
82+
const { cwd, configuredPackageManager, logger, dryRun } = options;
83+
const host = NodeJS_HOST;
84+
85+
const { name, source } = await determinePackageManager(
86+
host,
87+
cwd,
88+
configuredPackageManager,
89+
logger,
90+
);
91+
92+
if (!SUPPORTED_PACKAGE_MANAGERS[name]) {
93+
throw new Error(`Unsupported package manager: "${name}"`);
94+
}
95+
96+
// Do not verify if the package manager is installed during a dry run.
97+
if (!dryRun) {
98+
const isInstalled = await isPackageManagerInstalled(host, name, logger);
99+
if (!isInstalled) {
100+
if (source === 'default') {
101+
throw new Error(
102+
`'npm' was selected as the default package manager, but it is not installed or` +
103+
` cannot be found in the PATH. Please install 'npm' to continue.`,
104+
);
105+
} else {
106+
throw new Error(
107+
`The project is configured to use '${name}', but it is not installed or cannot be` +
108+
` found in the PATH. Please install '${name}' to continue.`,
109+
);
110+
}
111+
}
112+
}
113+
114+
logger?.debug(`Successfully created PackageManager for '${name}'.`);
115+
const descriptor = SUPPORTED_PACKAGE_MANAGERS[name];
116+
117+
return new PackageManager(host, cwd, descriptor, { dryRun, logger });
118+
}

0 commit comments

Comments
 (0)