diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 272454c21cbd..11fdd7b34410 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -12,7 +12,7 @@ * a flexible and secure abstraction over the various package managers. */ -import { join, relative, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import npa from 'npm-package-arg'; import { maxSatisfying, valid } from 'semver'; import { PackageManagerError } from './error'; @@ -154,9 +154,63 @@ export class PackageManager { this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }), ); + // `bun pm ls` has no per-workspace-member output. Run from a member, it lists the workspace + // root's dependencies, which are the members themselves (e.g. `app@workspace:app`). + if ( + this.descriptor.binary === 'bun' && + workspacePackageName && + dependencies?.get(workspacePackageName)?.version.startsWith('workspace:') + ) { + return (this.#dependencyCache = await this.#readDependenciesFromManifests()); + } + return (this.#dependencyCache = dependencies ?? new Map()); } + /** + * Builds the dependency map of the project in the working directory from its `package.json` + * and the manifests of the packages installed in `node_modules`. + * @returns A map of the declared dependencies that are installed to their installed details. + */ + async #readDependenciesFromManifests(): Promise> { + const dependencies = new Map(); + + let declared: Record | undefined>; + try { + declared = JSON.parse(await this.host.readFile(join(this.cwd, 'package.json'))); + } catch { + return dependencies; + } + + const names = new Set(); + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { + Object.keys(declared[field] ?? {}).forEach((name) => names.add(name)); + } + + for (const name of names) { + // Resolve the package like Node.js does: look in the `node_modules` of the project, then in + // those of its parent directories (hoisted workspace installs). + for (let dir = this.cwd; ; dir = dirname(dir)) { + const path = join(dir, 'node_modules', name); + try { + const { version } = JSON.parse(await this.host.readFile(join(path, 'package.json'))); + if (typeof version === 'string') { + dependencies.set(name, { name, version, path }); + } + break; + } catch { + // Not installed at this level. + } + + if (dirname(dir) === dir) { + break; + } + } + } + + return dependencies; + } + /** * A private method to run a command using the package manager's binary. * @param args The arguments to pass to the command. diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 37e0d8aae113..fd9934e815bf 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -445,6 +445,38 @@ describe('PackageManager', () => { expect([...dependencies.keys()]).toEqual(['@angular/core']); expect(dependencies.get('@angular/core')?.version).toBe('22.0.0'); }); + + it('should list the dependencies of a bun workspace member', async () => { + const pm = new PackageManager(host, '/repo/app', SUPPORTED_PACKAGE_MANAGERS['bun']); + // `bun pm ls` run from a member prints the root's dependencies, i.e. the members themselves. + runCommandSpy.and.resolveTo({ + stdout: '/repo node_modules (405 installed)\n└── app@workspace:app', + stderr: '', + }); + const files: Record = { + '/repo/app/package.json': JSON.stringify({ + name: 'app', + dependencies: { '@angular/core': '^22.0.0' }, + devDependencies: { typescript: '~5.9.0' }, + }), + '/repo/node_modules/@angular/core/package.json': + '{"name":"@angular/core","version":"22.2.3"}', + '/repo/node_modules/typescript/package.json': '{"name":"typescript","version":"5.9.3"}', + }; + spyOn(host, 'readFile').and.callFake((path: string) => { + const content = files[path.replace(/\\/g, '/')]; + + return content === undefined + ? Promise.reject(new Error('ENOENT')) + : Promise.resolve(content); + }); + + const dependencies = await pm.getProjectDependencies(); + + expect([...dependencies.keys()].sort()).toEqual(['@angular/core', 'typescript']); + expect(dependencies.get('@angular/core')?.version).toBe('22.2.3'); + expect(dependencies.get('typescript')?.version).toBe('5.9.3'); + }); }); describe('initializationError', () => {