Skip to content

Commit b87cafe

Browse files
committed
fix(@angular/cli): support resolving subproject dependencies in pnpm workspaces
When running dependency listing commands (like `pnpm list --depth=0 --json`) in a workspace subproject, the package manager resolves the workspace layout and runs in the context of the workspace root, returning an array of project objects representing all workspace members. Previously, `parseNpmLikeDependencies` blindly selected the first item in the array (`data[0]`), which corresponds to the workspace root package. Since the root package typically does not declare workspace member dependencies (like `@angular/core`), `ng update` failed with "Package X is not a dependency" errors. This updates `parseNpmLikeDependencies` to search the array for the project matching `workspacePackageName` if it is provided. If the selected project is the workspace member itself, it bypasses the nested workspace dependency lookup block, allowing its direct dependencies to be parsed correctly. (cherry picked from commit b0b3a47)
1 parent e85c10c commit b87cafe

2 files changed

Lines changed: 166 additions & 19 deletions

File tree

packages/angular/cli/src/package-managers/parsers.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ interface NpmListDependency {
7878
* @param logger An optional logger instance.
7979
* @returns A map of package names to their installed package details.
8080
*/
81+
interface NpmListProject {
82+
name?: string;
83+
dependencies?: Record<string, NpmListDependency>;
84+
devDependencies?: Record<string, NpmListDependency>;
85+
unsavedDependencies?: Record<string, NpmListDependency>;
86+
}
87+
8188
export function parseNpmLikeDependencies(
8289
stdout: string,
8390
logger?: Logger,
@@ -93,26 +100,55 @@ export function parseNpmLikeDependencies(
93100
return dependencies;
94101
}
95102

96-
let data = JSON.parse(stdout);
97-
if (Array.isArray(data)) {
98-
// pnpm returns an array of projects.
99-
data = data[0];
100-
}
103+
const data = JSON.parse(stdout);
104+
const workspacePackageName = options?.workspacePackageName;
101105

102-
const dependencyMaps = [data.dependencies, data.devDependencies, data.unsavedDependencies].filter(
103-
(d) => !!d,
104-
);
106+
let rootProject: NpmListProject | undefined;
107+
let subProject: NpmListProject | undefined;
105108

106-
if (dependencyMaps.length === 0) {
107-
logger?.debug(' `dependencies` property not found. No dependencies found.');
109+
if (Array.isArray(data)) {
110+
rootProject = data[0];
111+
if (workspacePackageName) {
112+
subProject = data.find(
113+
(project) => (project as NpmListProject | undefined)?.name === workspacePackageName,
114+
);
115+
}
116+
} else {
117+
rootProject = data;
118+
}
108119

120+
if (!rootProject || typeof rootProject !== 'object') {
109121
return dependencies;
110122
}
111123

112-
const workspacePackageName = options?.workspacePackageName;
113-
114-
if (workspacePackageName) {
115-
for (const dependencyMap of dependencyMaps) {
124+
// 1. Extract subproject dependencies first to prioritize them
125+
if (subProject) {
126+
const subprojectMaps = [
127+
subProject.dependencies,
128+
subProject.devDependencies,
129+
subProject.unsavedDependencies,
130+
].filter((d) => !!d) as Record<string, NpmListDependency>[];
131+
132+
for (const subprojectMap of subprojectMaps) {
133+
for (const [name, info] of Object.entries(subprojectMap)) {
134+
if (info && typeof info === 'object' && info.version) {
135+
dependencies.set(name, {
136+
name,
137+
version: info.version,
138+
path: info.path,
139+
});
140+
}
141+
}
142+
}
143+
} else if (workspacePackageName) {
144+
// Fallback to npm-like nested lookup inside the root project
145+
const rootDependencyMaps = [
146+
rootProject.dependencies,
147+
rootProject.devDependencies,
148+
rootProject.unsavedDependencies,
149+
].filter((d) => !!d) as Record<string, NpmListDependency>[];
150+
151+
for (const dependencyMap of rootDependencyMaps) {
116152
const info = dependencyMap[workspacePackageName];
117153
if (info && typeof info === 'object') {
118154
const nestedMaps = [
@@ -136,11 +172,15 @@ export function parseNpmLikeDependencies(
136172
}
137173
}
138174

139-
// Extract top-level dependencies (root), without overwriting subproject dependencies
140-
for (const dependencyMap of dependencyMaps) {
141-
for (const [name, info] of Object.entries(
142-
dependencyMap as Record<string, NpmListDependency & { resolved?: string }>,
143-
)) {
175+
// 2. Supplement with hoisted/root dependencies (without overwriting subproject dependencies)
176+
const rootDependencyMaps = [
177+
rootProject.dependencies,
178+
rootProject.devDependencies,
179+
rootProject.unsavedDependencies,
180+
].filter((d) => !!d) as Record<string, NpmListDependency & { resolved?: string }>[];
181+
182+
for (const dependencyMap of rootDependencyMaps) {
183+
for (const [name, info] of Object.entries(dependencyMap)) {
144184
if (!info || typeof info !== 'object') {
145185
continue;
146186
}

packages/angular/cli/src/package-managers/parsers_spec.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,113 @@ describe('parsers', () => {
6060
path: undefined,
6161
});
6262
});
63+
64+
it('should parse dependencies from array when workspacePackageName matches a project', () => {
65+
const stdout = JSON.stringify([
66+
{
67+
name: 'monorepo-root',
68+
version: '1.0.0',
69+
path: '/workspace',
70+
dependencies: {
71+
typescript: {
72+
version: '5.9.3',
73+
},
74+
},
75+
},
76+
{
77+
name: 'app',
78+
version: '1.0.0',
79+
path: '/workspace/apps/test-app',
80+
dependencies: {
81+
rxjs: {
82+
version: '7.8.2',
83+
},
84+
},
85+
},
86+
]);
87+
88+
const deps = parseNpmLikeDependencies(stdout, undefined, { workspacePackageName: 'app' });
89+
expect(deps.size).toBe(2);
90+
expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined });
91+
expect(deps.get('typescript')).toEqual({
92+
name: 'typescript',
93+
version: '5.9.3',
94+
path: undefined,
95+
});
96+
});
97+
98+
it('should parse dependencies from array when workspacePackageName matches the root project', () => {
99+
const stdout = JSON.stringify([
100+
{
101+
name: 'monorepo-root',
102+
version: '1.0.0',
103+
path: '/workspace',
104+
dependencies: {
105+
typescript: {
106+
version: '5.9.3',
107+
},
108+
},
109+
},
110+
{
111+
name: 'other-app',
112+
version: '1.0.0',
113+
path: '/workspace/apps/other-app',
114+
dependencies: {
115+
rxjs: {
116+
version: '7.8.2',
117+
},
118+
},
119+
},
120+
]);
121+
122+
const deps = parseNpmLikeDependencies(stdout, undefined, {
123+
workspacePackageName: 'monorepo-root',
124+
});
125+
expect(deps.size).toBe(1);
126+
expect(deps.get('typescript')).toEqual({
127+
name: 'typescript',
128+
version: '5.9.3',
129+
path: undefined,
130+
});
131+
});
132+
133+
it('should parse nested dependencies and supplement with hoisted root dependencies in npm workspaces', () => {
134+
const stdout = JSON.stringify({
135+
name: 'monorepo-root',
136+
version: '1.0.0',
137+
dependencies: {
138+
app: {
139+
version: '1.0.0',
140+
resolved: 'file:apps/test-app',
141+
dependencies: {
142+
rxjs: {
143+
version: '7.8.2',
144+
},
145+
},
146+
},
147+
typescript: {
148+
version: '5.9.3',
149+
},
150+
},
151+
});
152+
153+
const deps = parseNpmLikeDependencies(stdout, undefined, {
154+
workspacePackageName: 'app',
155+
});
156+
expect(deps.size).toBe(2);
157+
expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined });
158+
expect(deps.get('typescript')).toEqual({
159+
name: 'typescript',
160+
version: '5.9.3',
161+
path: undefined,
162+
});
163+
});
164+
165+
it('should return empty map and not throw on empty array or non-object json', () => {
166+
expect(parseNpmLikeDependencies('[]').size).toBe(0);
167+
expect(parseNpmLikeDependencies('null').size).toBe(0);
168+
expect(parseNpmLikeDependencies('"not-an-object"').size).toBe(0);
169+
});
63170
});
64171

65172
describe('parseNpmLikeError', () => {

0 commit comments

Comments
 (0)