From 2085fdb49e445bc9eb31151230c1d5c8200e1ac5 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:35:06 -0400 Subject: [PATCH] fix(@angular/cli): introduce initial package manager workspace awareness This change adds the getCurrentPackageName method to the package manager abstraction. When dealing with workspaces (such as npm workspaces), the parser uses the resolved identity of the active package to prioritize dependencies belonging directly to that subproject. It ensures that running ng update inside a subproject directory resolves the dependency versions declared for that subproject, while gracefully falling back to root hoisted versions for shared dependencies. --- .../package-manager-descriptor.ts | 11 +++- .../src/package-managers/package-manager.ts | 28 +++++++++- .../cli/src/package-managers/parsers.ts | 56 +++++++++++++++++-- .../cli/src/package-managers/parsers_spec.ts | 46 +++++++++++++++ 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index 34db06b64c99..2dfe75ee01cd 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -87,6 +87,9 @@ export interface PackageManagerDescriptor { /** The command to list all installed dependencies. */ readonly listDependenciesCommand: readonly string[]; + /** The command to get the current package name. */ + readonly getPackageNameCommand?: readonly string[]; + /** The command to fetch the registry manifest of a package. */ readonly getManifestCommand: readonly string[]; @@ -99,7 +102,11 @@ export interface PackageManagerDescriptor { /** A collection of functions to parse the output of specific commands. */ readonly outputParsers: { /** A function to parse the output of `listDependenciesCommand`. */ - listDependencies: (stdout: string, logger?: Logger) => Map; + listDependencies: ( + stdout: string, + logger?: Logger, + options?: { workspacePackageName?: string }, + ) => Map; /** A function to parse the output of `getManifestCommand` for a specific version. */ getRegistryManifest: (stdout: string, logger?: Logger) => PackageManifest | null; @@ -158,6 +165,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json=true', '--all=true'], + getPackageNameCommand: ['pkg', 'get', 'name'], getManifestCommand: ['view', '--json'], viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { @@ -237,6 +245,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json'], + getPackageNameCommand: ['pkg', 'get', 'name'], getManifestCommand: ['view', '--json'], viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 0dfb89e57371..33b8b07d48e3 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -145,8 +145,9 @@ 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), + this.descriptor.outputParsers.listDependencies(stdout, logger, { workspacePackageName }), ); return (this.#dependencyCache = dependencies ?? new Map()); @@ -361,6 +362,31 @@ export class PackageManager { this.#dependencyCache = null; } + /** + * Gets the name of the package in the current project. + */ + async getCurrentPackageName(): Promise { + if (this.descriptor.getPackageNameCommand) { + try { + const { stdout } = await this.#run(this.descriptor.getPackageNameCommand); + if (stdout) { + return JSON.parse(stdout); + } + } catch { + // Fall back to reading file if command fails + } + } + + try { + const content = await this.host.readFile(join(this.cwd, 'package.json')); + const pkgJson = JSON.parse(content); + + return pkgJson.name; + } catch { + return undefined; + } + } + /** * Gets the version of the package manager binary. */ diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index c9f7fb235087..0fe498c12331 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -81,6 +81,7 @@ interface NpmListDependency { export function parseNpmLikeDependencies( stdout: string, logger?: Logger, + options?: { workspacePackageName?: string }, ): Map { logger?.debug(`Parsing npm-like dependency list...`); logStdout(stdout, logger); @@ -108,13 +109,56 @@ export function parseNpmLikeDependencies( return dependencies; } + const workspacePackageName = options?.workspacePackageName; + + if (workspacePackageName) { + for (const dependencyMap of dependencyMaps) { + const info = dependencyMap[workspacePackageName]; + if (info && typeof info === 'object') { + const nestedMaps = [ + info.dependencies, + info.devDependencies, + info.unsavedDependencies, + ].filter((d) => !!d) as Record[]; + + for (const nestedMap of nestedMaps) { + for (const [name, nestedInfo] of Object.entries(nestedMap)) { + if (nestedInfo && typeof nestedInfo === 'object' && nestedInfo.version) { + dependencies.set(name, { + name, + version: nestedInfo.version, + path: nestedInfo.path, + }); + } + } + } + } + } + } + + // Extract top-level dependencies (root), without overwriting subproject dependencies for (const dependencyMap of dependencyMaps) { - for (const [name, info] of Object.entries(dependencyMap as Record)) { - dependencies.set(name, { - name, - version: info.version, - path: info.path, - }); + for (const [name, info] of Object.entries( + dependencyMap as Record, + )) { + if (!info || typeof info !== 'object') { + continue; + } + + // Exclude local monorepo workspace packages (which originate from a local file/dir + // and contain nested dependency maps in the output of `npm list --depth=0`), + // while preserving third-party packages installed from local paths. + const isWorkspacePackage = + info.resolved?.startsWith('file:') && + (!!info.dependencies || !!info.devDependencies || !!info.unsavedDependencies); + + if (info.version && !dependencies.has(name) && !isWorkspacePackage) { + dependencies.set(name, { + name, + version: info.version, + path: info.path, + }); + } } } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 2fa8abdc1e32..6d21300c7009 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -8,6 +8,7 @@ import { parseBunDependencies, + parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, parseYarnClassicDependencies, @@ -16,6 +17,51 @@ import { } from './parsers'; describe('parsers', () => { + describe('parseNpmLikeDependencies', () => { + it('should parse simple dependencies', () => { + const stdout = JSON.stringify({ + dependencies: { + rxjs: { + version: '7.8.2', + }, + }, + }); + const deps = parseNpmLikeDependencies(stdout); + expect(deps.size).toBe(1); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined }); + }); + + it('should parse dependencies from current subproject and hoisted root', () => { + const stdout = JSON.stringify({ + version: '1.0.0', + name: 'monorepo-root', + dependencies: { + app: { + version: '1.0.0', + resolved: 'file:../packages/app', + dependencies: { + rxjs: { + version: '7.8.1', + }, + }, + }, + typescript: { + version: '5.9.3', + }, + }, + }); + + const deps = parseNpmLikeDependencies(stdout, undefined, { workspacePackageName: 'app' }); + expect(deps.size).toBe(2); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.1', path: undefined }); + expect(deps.get('typescript')).toEqual({ + name: 'typescript', + version: '5.9.3', + path: undefined, + }); + }); + }); + describe('parseNpmLikeError', () => { it('should parse a structured JSON error from modern yarn', () => { const stdout = JSON.stringify({