Skip to content

Commit b69d85b

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: - An improved package manager discovery process especially in monorepo scenarios. - 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 05ba42e commit b69d85b

File tree

12 files changed

+1740
-0
lines changed

12 files changed

+1740
-0
lines changed
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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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': true, // .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+
84+
it('should prioritize the closest lockfile, regardless of precedence', async () => {
85+
const host = new MockHost({
86+
'/project': ['pnpm-lock.yaml'], // Higher precedence
87+
'/project/subdir': ['package-lock.json'], // Lower precedence
88+
});
89+
const result = await discover(host, '/project/subdir');
90+
// Should find 'npm' and stop, not continue to find 'pnpm'.
91+
expect(result).toBe('npm');
92+
});
93+
94+
it('should find a lockfile in the git root directory', async () => {
95+
const host = new MockHost({
96+
'/project': ['yarn.lock'],
97+
'/project/.git': true,
98+
'/project/subdir': [],
99+
});
100+
const result = await discover(host, '/project/subdir');
101+
expect(result).toBe('yarn');
102+
});
103+
104+
it('should discover the alternate npm lockfile name', async () => {
105+
const host = new MockHost({
106+
'/project': ['npm-shrinkwrap.json'],
107+
});
108+
const result = await discover(host, '/project');
109+
expect(result).toBe('npm');
110+
});
111+
112+
it('should discover the alternate bun lockfile name', async () => {
113+
const host = new MockHost({
114+
'/project': ['bun.lockb'],
115+
});
116+
const result = await discover(host, '/project');
117+
expect(result).toBe('bun');
118+
});
119+
});
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+
}

0 commit comments

Comments
 (0)