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
136 changes: 136 additions & 0 deletions packages/angular/cli/src/package-managers/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

/**
* @fileoverview This file contains the logic for discovering the package manager
* used in a project by searching for lockfiles. It is designed to be efficient
* and to correctly handle monorepo structures.
*/

import { dirname, join } from 'node:path';
import { Host } from './host';
import { Logger } from './logger';
import {
PACKAGE_MANAGER_PRECEDENCE,
PackageManagerName,
SUPPORTED_PACKAGE_MANAGERS,
} from './package-manager-descriptor';

/**
* A map from lockfile names to their corresponding package manager.
* This is a performance optimization to avoid iterating over all possible
* lockfiles in every directory.
*/
const LOCKFILE_TO_PACKAGE_MANAGER = new Map<string, PackageManagerName>();
for (const [name, descriptor] of Object.entries(SUPPORTED_PACKAGE_MANAGERS)) {
for (const lockfile of descriptor.lockfiles) {
LOCKFILE_TO_PACKAGE_MANAGER.set(lockfile, name as PackageManagerName);
}
}

/**
* Searches a directory for lockfiles and returns a set of package managers that correspond to them.
* @param host A `Host` instance for interacting with the file system.
* @param directory The directory to search.
* @param logger An optional logger instance.
* @returns A promise that resolves to a set of package manager names.
*/
async function findLockfiles(
host: Host,
directory: string,
logger?: Logger,
): Promise<Set<PackageManagerName>> {
logger?.debug(`Searching for lockfiles in '${directory}'...`);

try {
const files = await host.readdir(directory);
const foundPackageManagers = new Set<PackageManagerName>();

for (const file of files) {
const packageManager = LOCKFILE_TO_PACKAGE_MANAGER.get(file);
if (packageManager) {
logger?.debug(` Found '${file}'.`);
foundPackageManagers.add(packageManager);
}
}

return foundPackageManagers;
} catch (e) {
logger?.debug(` Failed to read directory: ${e}`);

// Ignore directories that don't exist or can't be read.
return new Set();
}
}

/**
* Checks if a given path is a directory.
* @param host A `Host` instance for interacting with the file system.
* @param path The path to check.
* @returns A promise that resolves to true if the path is a directory, false otherwise.
*/
async function isDirectory(host: Host, path: string): Promise<boolean> {
try {
return (await host.stat(path)).isDirectory();
} catch {
return false;
}
}

/**
* Discovers the package manager used in a project by searching for lockfiles.
*
* This function searches for lockfiles in the given directory and its ancestors.
* If multiple lockfiles are found, it uses the precedence array to determine
* which package manager to use. The search is bounded by the git repository root.
*
* @param host A `Host` instance for interacting with the file system.
* @param startDir The directory to start the search from.
* @param logger An optional logger instance.
* @returns A promise that resolves to the name of the discovered package manager, or null if none is found.
*/
export async function discover(
host: Host,
startDir: string,
logger?: Logger,
): Promise<PackageManagerName | null> {
logger?.debug(`Starting package manager discovery in '${startDir}'...`);
let currentDir = startDir;

while (true) {
const found = await findLockfiles(host, currentDir, logger);

if (found.size > 0) {
logger?.debug(`Found lockfile(s): [${[...found].join(', ')}]. Applying precedence...`);
for (const packageManager of PACKAGE_MANAGER_PRECEDENCE) {
if (found.has(packageManager)) {
logger?.debug(`Selected '${packageManager}' based on precedence.`);

return packageManager;
}
}
}

// Stop searching if we reach the git repository root.
if (await isDirectory(host, join(currentDir, '.git'))) {
logger?.debug(`Reached repository root at '${currentDir}'. Stopping search.`);

return null;
}

const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// We have reached the filesystem root.
logger?.debug('Reached filesystem root. No lockfile found.');

return null;
}

currentDir = parentDir;
}
}
119 changes: 119 additions & 0 deletions packages/angular/cli/src/package-managers/discovery_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { discover } from './discovery';
import { MockHost } from './testing/mock-host';

describe('discover', () => {
it('should find a lockfile in the starting directory', async () => {
const host = new MockHost({
'/project': ['package-lock.json'],
});
const result = await discover(host, '/project');
expect(result).toBe('npm');
});

it('should find a lockfile in a parent directory', async () => {
const host = new MockHost({
'/project': ['yarn.lock'],
'/project/subdir': [],
});
const result = await discover(host, '/project/subdir');
expect(result).toBe('yarn');
});

it('should return null if no lockfile is found up to the root', async () => {
const host = new MockHost({
'/': [],
'/project': [],
'/project/subdir': [],
});
const result = await discover(host, '/project/subdir');
expect(result).toBeNull();
});

it('should apply precedence when multiple lockfiles are found', async () => {
const host = new MockHost({
'/project': ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'],
});
// pnpm should have the highest precedence according to the descriptor.
const result = await discover(host, '/project');
expect(result).toBe('pnpm');
});

it('should stop searching at a .git boundary', async () => {
const host = new MockHost({
'/': ['yarn.lock'],
'/project/.git': true, // .git is mocked as a directory.
'/project/subdir': [],
});
const result = await discover(host, '/project/subdir');
expect(result).toBeNull();
});

it('should stop searching at the filesystem root', async () => {
const host = new MockHost({
'/': [],
});
const result = await discover(host, '/');
expect(result).toBeNull();
});

it('should handle file system errors during readdir gracefully', async () => {
const host = new MockHost({});
host.readdir = () => Promise.reject(new Error('Permission denied'));

const result = await discover(host, '/project');
expect(result).toBeNull();
});

it('should handle file system errors during stat gracefully', async () => {
const host = new MockHost({ '/project': ['.git'] });
host.stat = () => Promise.reject(new Error('Permission denied'));

// The error on stat should prevent it from finding the .git dir and thus it will continue to the root.
const result = await discover(host, '/project');
expect(result).toBeNull();
});

it('should prioritize the closest lockfile, regardless of precedence', async () => {
const host = new MockHost({
'/project': ['pnpm-lock.yaml'], // Higher precedence
'/project/subdir': ['package-lock.json'], // Lower precedence
});
const result = await discover(host, '/project/subdir');
// Should find 'npm' and stop, not continue to find 'pnpm'.
expect(result).toBe('npm');
});

it('should find a lockfile in the git root directory', async () => {
const host = new MockHost({
'/project': ['yarn.lock'],
'/project/.git': true,
'/project/subdir': [],
});
const result = await discover(host, '/project/subdir');
expect(result).toBe('yarn');
});

it('should discover the alternate npm lockfile name', async () => {
const host = new MockHost({
'/project': ['npm-shrinkwrap.json'],
});
const result = await discover(host, '/project');
expect(result).toBe('npm');
});

it('should discover the alternate bun lockfile name', async () => {
const host = new MockHost({
'/project': ['bun.lockb'],
});
const result = await discover(host, '/project');
expect(result).toBe('bun');
});
});
37 changes: 37 additions & 0 deletions packages/angular/cli/src/package-managers/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

/**
* @fileoverview This file defines a custom error class for the package manager
* abstraction. This allows for structured error handling and provides consumers
* with detailed information about the process failure.
*/

/**
* A custom error class for package manager-related errors.
*
* This error class provides structured data about the failed process,
* including stdout, stderr, and the exit code.
*/
export class PackageManagerError extends Error {
/**
* Creates a new `PackageManagerError` instance.
* @param message The error message.
* @param stdout The standard output of the failed process.
* @param stderr The standard error of the failed process.
* @param exitCode The exit code of the failed process.
*/
constructor(
message: string,
public readonly stdout: string,
public readonly stderr: string,
public readonly exitCode: number | null,
) {
super(message);
}
}
Loading