Skip to content

Commit e6f9ea1

Browse files
committed
fix(@angular/cli): support resolving subproject dependencies in bun workspaces
When running `bun pm ls` inside a workspace member directory, Bun resolves the workspace root lockfile and outputs only the direct dependencies declared in the root `package.json`, along with `workspace:` references to member packages. Because subproject dependencies (such as `@angular/core`) are omitted from this output, commands like `ng update` and `ng add` fail to detect installed project dependencies when executed in a Bun workspace member. To resolve this, `parseBunDependencies` now ignores `workspace:` protocol entries, and the `bun` package manager descriptor enables `requiresManifestDependencyLookup`. When populating the dependency cache, `PackageManager` supplements the command output by reading `dependencies` and `devDependencies` from the working directory's `package.json` and resolving their installed manifests across ancestor `node_modules` directories up to the workspace lockfile or repository root. Additionally, `getPackageNameCommand` is configured for `bun` using `bun pm pkg get name` to provide the same CLI fallback as `npm` and `pnpm`. Fixes #34402
1 parent 50b12c4 commit e6f9ea1

6 files changed

Lines changed: 394 additions & 4 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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 { dirname, join } from 'node:path';
10+
import type { Host } from './host';
11+
import type { InstalledPackage } from './package-tree';
12+
13+
/**
14+
* Maximum number of concurrent file system read operations to prevent OS file descriptor exhaustion.
15+
*/
16+
const MAX_CONCURRENT_READS = 16;
17+
18+
/**
19+
* Maps each item to a result using an asynchronous function, running at most `limit` operations
20+
* concurrently. Results are returned in the same order as the input items.
21+
*/
22+
async function mapConcurrent<T, R>(
23+
items: readonly T[],
24+
limit: number,
25+
fn: (item: T) => Promise<R>,
26+
): Promise<R[]> {
27+
const results = new Array<R>(items.length);
28+
let index = 0;
29+
30+
const worker = async () => {
31+
while (index < items.length) {
32+
const i = index++;
33+
results[i] = await fn(items[i]);
34+
}
35+
};
36+
37+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
38+
39+
return results;
40+
}
41+
42+
/**
43+
* Checks whether a path exists and matches the expected type.
44+
*/
45+
async function isPathOfType(
46+
host: Host,
47+
path: string,
48+
type: 'file' | 'directory' | 'any',
49+
): Promise<boolean> {
50+
try {
51+
const stats = await host.stat(path);
52+
53+
switch (type) {
54+
case 'file':
55+
return stats.isFile();
56+
case 'directory':
57+
return stats.isDirectory();
58+
case 'any':
59+
return true;
60+
}
61+
} catch {
62+
return false;
63+
}
64+
}
65+
66+
/**
67+
* Finds existing `node_modules` directories starting from `startDir` and walking up
68+
* ancestor directories until reaching a directory with one of `lockfiles` or the git repository root.
69+
*/
70+
async function findNodeModulesDirectories(
71+
host: Host,
72+
startDir: string,
73+
lockfiles: readonly string[],
74+
): Promise<string[]> {
75+
const directories: string[] = [];
76+
let currentDir = startDir;
77+
78+
while (true) {
79+
const nodeModulesPath = join(currentDir, 'node_modules');
80+
const [hasNodeModules, isGitRoot, ...lockfileChecks] = await Promise.all([
81+
isPathOfType(host, nodeModulesPath, 'directory'),
82+
// `.git` is a file (not a directory) in git worktrees and submodules.
83+
isPathOfType(host, join(currentDir, '.git'), 'any'),
84+
...lockfiles.map((lockfile) => isPathOfType(host, join(currentDir, lockfile), 'file')),
85+
]);
86+
87+
if (hasNodeModules) {
88+
directories.push(nodeModulesPath);
89+
}
90+
91+
// Stop walking up once we reach a directory containing the package manager's lockfile
92+
// or the git repository root.
93+
if (isGitRoot || lockfileChecks.some(Boolean)) {
94+
break;
95+
}
96+
97+
const parentDir = dirname(currentDir);
98+
if (parentDir === currentDir) {
99+
break;
100+
}
101+
currentDir = parentDir;
102+
}
103+
104+
return directories;
105+
}
106+
107+
/**
108+
* Resolves the installed manifest of a package by searching the given `node_modules`
109+
* directories in order.
110+
*/
111+
async function resolveInstalledPackage(
112+
host: Host,
113+
packageName: string,
114+
nodeModulesDirs: readonly string[],
115+
): Promise<InstalledPackage | undefined> {
116+
for (const nodeModulesDir of nodeModulesDirs) {
117+
const packagePath = join(nodeModulesDir, packageName);
118+
try {
119+
const pkgContent = await host.readFile(join(packagePath, 'package.json'));
120+
const { version } = JSON.parse(pkgContent) as { version?: unknown };
121+
if (typeof version === 'string') {
122+
return { name: packageName, version, path: packagePath };
123+
}
124+
} catch {
125+
// Continue searching ancestor node_modules directories.
126+
}
127+
}
128+
129+
return undefined;
130+
}
131+
132+
/**
133+
* Resolves installed versions of dependencies declared in the given directory's `package.json`
134+
* by searching ancestor `node_modules` directories starting from `cwd`.
135+
*
136+
* The returned map preserves the declaration order of the manifest.
137+
*/
138+
export async function resolveManifestDependencies(
139+
host: Host,
140+
cwd: string,
141+
lockfiles: readonly string[],
142+
): Promise<Map<string, InstalledPackage>> {
143+
const resolvedDependencies = new Map<string, InstalledPackage>();
144+
145+
let manifest: {
146+
dependencies?: Record<string, string>;
147+
devDependencies?: Record<string, string>;
148+
};
149+
try {
150+
manifest = JSON.parse(await host.readFile(join(cwd, 'package.json')));
151+
} catch {
152+
return resolvedDependencies;
153+
}
154+
155+
if (!manifest || typeof manifest !== 'object') {
156+
return resolvedDependencies;
157+
}
158+
159+
const declaredPackages = new Set<string>();
160+
for (const depMap of [manifest.dependencies, manifest.devDependencies]) {
161+
if (!depMap || typeof depMap !== 'object') {
162+
continue;
163+
}
164+
for (const [name, specifier] of Object.entries(depMap)) {
165+
if (typeof specifier === 'string' && !specifier.startsWith('workspace:')) {
166+
declaredPackages.add(name);
167+
}
168+
}
169+
}
170+
171+
if (declaredPackages.size === 0) {
172+
return resolvedDependencies;
173+
}
174+
175+
const nodeModulesDirs = await findNodeModulesDirectories(host, cwd, lockfiles);
176+
if (nodeModulesDirs.length === 0) {
177+
return resolvedDependencies;
178+
}
179+
180+
const installedPackages = await mapConcurrent(
181+
[...declaredPackages],
182+
MAX_CONCURRENT_READS,
183+
(packageName) => resolveInstalledPackage(host, packageName, nodeModulesDirs),
184+
);
185+
186+
for (const installedPackage of installedPackages) {
187+
if (installedPackage) {
188+
resolvedDependencies.set(installedPackage.name, installedPackage);
189+
}
190+
}
191+
192+
return resolvedDependencies;
193+
}

‎packages/angular/cli/src/package-managers/package-manager-descriptor.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ export interface PackageManagerDescriptor {
9090
/** The command to list all installed dependencies. */
9191
readonly listDependenciesCommand: readonly string[];
9292

93+
/**
94+
* Whether declared dependencies in the current directory's `package.json` should be
95+
* resolved from `node_modules` to supplement `listDependenciesCommand`.
96+
*
97+
* When enabled, this lookup runs for every project using the package manager (not only
98+
* workspace members). Resolved entries take precedence over the command output and include
99+
* the installed package path. This is required when `listDependenciesCommand` does not
100+
* report a workspace member's own dependencies (e.g., `bun pm ls`).
101+
*/
102+
readonly requiresManifestDependencyLookup?: boolean;
103+
93104
/** The command to get the release age configuration value. */
94105
readonly getReleaseAgeConfigCommand?: readonly string[];
95106

@@ -297,6 +308,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
297308
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
298309
versionCommand: ['--version'],
299310
listDependenciesCommand: ['pm', 'ls'],
311+
// `bun pm ls` only outputs root workspace dependencies when run in a workspace member.
312+
// See: https://github.com/oven-sh/bun/issues/10008
313+
requiresManifestDependencyLookup: true,
314+
getPackageNameCommand: ['pm', 'pkg', 'get', 'name'],
300315
getManifestCommand: ['pm', 'view', '--json'],
301316
getRegistryMetadata: async (packageName, fetchAndParse) => {
302317
const [distTags, versions] = await Promise.all([

‎packages/angular/cli/src/package-managers/package-manager.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { maxSatisfying, valid } from 'semver';
1818
import { PackageManagerError } from './error';
1919
import { Host } from './host';
2020
import { Logger } from './logger';
21+
import { resolveManifestDependencies } from './manifest-dependencies';
2122
import { PackageManagerDescriptor } from './package-manager-descriptor';
2223
import { PackageManifest, PackageMetadata } from './package-metadata';
2324
import { InstalledPackage } from './package-tree';
@@ -150,11 +151,26 @@ export class PackageManager {
150151
const args = this.descriptor.listDependenciesCommand;
151152

152153
const workspacePackageName = await this.getCurrentPackageName();
153-
const dependencies = await this.#fetchAndParse(args, (stdout, logger) =>
154-
this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }),
155-
);
154+
const dependencies =
155+
(await this.#fetchAndParse(args, (stdout, logger) =>
156+
this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }),
157+
)) ?? new Map<string, InstalledPackage>();
158+
159+
if (this.descriptor.requiresManifestDependencyLookup) {
160+
const manifestDependencies = await resolveManifestDependencies(
161+
this.host,
162+
this.cwd,
163+
this.descriptor.lockfiles,
164+
);
165+
// Entries resolved from disk intentionally override the command output. In a workspace
166+
// member, they reflect the member's actual installed version (which may be nested and
167+
// differ from the hoisted root version) and also provide the package path.
168+
for (const [name, pkg] of manifestDependencies) {
169+
dependencies.set(name, pkg);
170+
}
171+
}
156172

157-
return (this.#dependencyCache = dependencies ?? new Map());
173+
return (this.#dependencyCache = dependencies);
158174
}
159175

160176
/**

0 commit comments

Comments
 (0)