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
193 changes: 193 additions & 0 deletions packages/angular/cli/src/package-managers/manifest-dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* @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 { dirname, join } from 'node:path';
import type { Host } from './host';
import type { InstalledPackage } from './package-tree';

/**
* Maximum number of concurrent file system read operations to prevent OS file descriptor exhaustion.
*/
const MAX_CONCURRENT_READS = 16;

/**
* Maps each item to a result using an asynchronous function, running at most `limit` operations
* concurrently. Results are returned in the same order as the input items.
*/
async function mapConcurrent<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let index = 0;

const worker = async () => {
while (index < items.length) {
const i = index++;
results[i] = await fn(items[i]);
}
};

await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));

return results;
}

/**
* Checks whether a path exists and matches the expected type.
*/
async function isPathOfType(
host: Host,
path: string,
type: 'file' | 'directory' | 'any',
): Promise<boolean> {
try {
const stats = await host.stat(path);

switch (type) {
case 'file':
return stats.isFile();
case 'directory':
return stats.isDirectory();
case 'any':
return true;
}
} catch {
return false;
}
}

/**
* Finds existing `node_modules` directories starting from `startDir` and walking up
* ancestor directories until reaching a directory with one of `lockfiles` or the git repository root.
*/
async function findNodeModulesDirectories(
host: Host,
startDir: string,
lockfiles: readonly string[],
): Promise<string[]> {
const directories: string[] = [];
let currentDir = startDir;

while (true) {
const nodeModulesPath = join(currentDir, 'node_modules');
const [hasNodeModules, isGitRoot, ...lockfileChecks] = await Promise.all([
isPathOfType(host, nodeModulesPath, 'directory'),
// `.git` is a file (not a directory) in git worktrees and submodules.
isPathOfType(host, join(currentDir, '.git'), 'any'),
...lockfiles.map((lockfile) => isPathOfType(host, join(currentDir, lockfile), 'file')),
]);

if (hasNodeModules) {
directories.push(nodeModulesPath);
}

// Stop walking up once we reach a directory containing the package manager's lockfile
// or the git repository root.
if (isGitRoot || lockfileChecks.some(Boolean)) {
break;
}

const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}

return directories;
}

/**
* Resolves the installed manifest of a package by searching the given `node_modules`
* directories in order.
*/
async function resolveInstalledPackage(
host: Host,
packageName: string,
nodeModulesDirs: readonly string[],
): Promise<InstalledPackage | undefined> {
for (const nodeModulesDir of nodeModulesDirs) {
const packagePath = join(nodeModulesDir, packageName);
try {
const pkgContent = await host.readFile(join(packagePath, 'package.json'));
const { version } = JSON.parse(pkgContent) as { version?: unknown };
if (typeof version === 'string') {
return { name: packageName, version, path: packagePath };
}
} catch {
// Continue searching ancestor node_modules directories.
}
}

return undefined;
}

/**
* Resolves installed versions of dependencies declared in the given directory's `package.json`
* by searching ancestor `node_modules` directories starting from `cwd`.
*
* The returned map preserves the declaration order of the manifest.
*/
export async function resolveManifestDependencies(
host: Host,
cwd: string,
lockfiles: readonly string[],
): Promise<Map<string, InstalledPackage>> {
const resolvedDependencies = new Map<string, InstalledPackage>();

let manifest: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
try {
manifest = JSON.parse(await host.readFile(join(cwd, 'package.json')));
} catch {
return resolvedDependencies;
}

if (!manifest || typeof manifest !== 'object') {
return resolvedDependencies;
}

const declaredPackages = new Set<string>();
for (const depMap of [manifest.dependencies, manifest.devDependencies]) {
Comment thread
clydin marked this conversation as resolved.
if (!depMap || typeof depMap !== 'object') {
continue;
}
for (const [name, specifier] of Object.entries(depMap)) {
if (typeof specifier === 'string' && !specifier.startsWith('workspace:')) {
declaredPackages.add(name);
}
}
}

if (declaredPackages.size === 0) {
return resolvedDependencies;
}

const nodeModulesDirs = await findNodeModulesDirectories(host, cwd, lockfiles);
if (nodeModulesDirs.length === 0) {
return resolvedDependencies;
}

const installedPackages = await mapConcurrent(
[...declaredPackages],
MAX_CONCURRENT_READS,
(packageName) => resolveInstalledPackage(host, packageName, nodeModulesDirs),
);

for (const installedPackage of installedPackages) {
if (installedPackage) {
resolvedDependencies.set(installedPackage.name, installedPackage);
}
}

return resolvedDependencies;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PackageManifest, PackageMetadata } from './package-metadata';
import { InstalledPackage } from './package-tree';
import {
parseBunDependencies,
parseBunError,
parseNpmBeforeDate,
parseNpmLikeDependencies,
parseNpmLikeError,
Expand Down Expand Up @@ -90,6 +91,17 @@ export interface PackageManagerDescriptor {
/** The command to list all installed dependencies. */
readonly listDependenciesCommand: readonly string[];

/**
* Whether declared dependencies in the current directory's `package.json` should be
* resolved from `node_modules` to supplement `listDependenciesCommand`.
*
* When enabled, this lookup runs for every project using the package manager (not only
* workspace members). Resolved entries take precedence over the command output and include
* the installed package path. This is required when `listDependenciesCommand` does not
* report a workspace member's own dependencies (e.g., `bun pm ls`).
*/
readonly requiresManifestDependencyLookup?: boolean;

/** The command to get the release age configuration value. */
readonly getReleaseAgeConfigCommand?: readonly string[];

Expand Down Expand Up @@ -297,6 +309,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['pm', 'ls'],
// `bun pm ls` only outputs root workspace dependencies when run in a workspace member.
// See: https://github.com/oven-sh/bun/issues/10008
requiresManifestDependencyLookup: true,
getPackageNameCommand: ['pm', 'pkg', 'get', 'name'],
getManifestCommand: ['pm', 'view', '--json'],
getRegistryMetadata: async (packageName, fetchAndParse) => {
const [distTags, versions] = await Promise.all([
Expand Down Expand Up @@ -334,7 +350,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependencies: parseBunDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
getError: parseBunError,
},
isNotFound: isKnownNotFound,
},
Expand Down
24 changes: 20 additions & 4 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { maxSatisfying, valid } from 'semver';
import { PackageManagerError } from './error';
import { Host } from './host';
import { Logger } from './logger';
import { resolveManifestDependencies } from './manifest-dependencies';
import { PackageManagerDescriptor } from './package-manager-descriptor';
import { PackageManifest, PackageMetadata } from './package-metadata';
import { InstalledPackage } from './package-tree';
Expand Down Expand Up @@ -150,11 +151,26 @@ export class PackageManager {
const args = this.descriptor.listDependenciesCommand;

const workspacePackageName = await this.getCurrentPackageName();
const dependencies = await this.#fetchAndParse(args, (stdout, logger) =>
this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }),
);
const dependencies =
(await this.#fetchAndParse(args, (stdout, logger) =>
this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }),
)) ?? new Map<string, InstalledPackage>();

if (this.descriptor.requiresManifestDependencyLookup) {
const manifestDependencies = await resolveManifestDependencies(
this.host,
this.cwd,
this.descriptor.lockfiles,
);
// Entries resolved from disk intentionally override the command output. In a workspace
// member, they reflect the member's actual installed version (which may be nested and
// differ from the hoisted root version) and also provide the package path.
for (const [name, pkg] of manifestDependencies) {
dependencies.set(name, pkg);
}
}

return (this.#dependencyCache = dependencies ?? new Map());
return (this.#dependencyCache = dependencies);
}

/**
Expand Down
Loading
Loading