Skip to content

Commit eb893b9

Browse files
committed
fix(@angular/cli): isolate temporary package installation from parent pnpm workspace
Write an empty pnpm-workspace.yaml file inside the temporary installation directory when using pnpm. This acts as a workspace boundary, preventing pnpm from searching up the directory tree and modifying the parent workspace's lockfile during ng update.
1 parent 11a4438 commit eb893b9

4 files changed

Lines changed: 106 additions & 6 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,12 @@ export class PackageManager {
647647
// Writing an empty package.json file beforehand prevents this.
648648
await this.host.writeFile(join(workingDirectory, 'package.json'), '{}');
649649

650+
// To prevent pnpm from traversing up the directory tree and modifying the project's workspace lockfile,
651+
// write a blank `pnpm-workspace.yaml` in the temporary directory.
652+
if (this.name === 'pnpm') {
653+
await this.host.writeFile(join(workingDirectory, 'pnpm-workspace.yaml'), '');
654+
}
655+
650656
// Copy configuration files if the package manager requires it (e.g., bun).
651657
if (this.descriptor.copyConfigFromProject) {
652658
for (const configFile of this.descriptor.configFiles) {

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,58 @@ describe('PackageManager', () => {
8686
});
8787
});
8888

89+
describe('acquireTempPackage', () => {
90+
it('should write pnpm-workspace.yaml when package manager is pnpm', async () => {
91+
const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm'];
92+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
93+
const pm = new PackageManager(testHost, '/tmp/project', pnpmDescriptor);
94+
95+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
96+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
97+
);
98+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
99+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
100+
101+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
102+
103+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
104+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
105+
expect(writeFileSpy).toHaveBeenCalledWith(
106+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
107+
'{}',
108+
);
109+
expect(writeFileSpy).toHaveBeenCalledWith(
110+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
111+
'',
112+
);
113+
});
114+
115+
it('should NOT write pnpm-workspace.yaml when package manager is npm', async () => {
116+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
117+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
118+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
119+
120+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
121+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
122+
);
123+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
124+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
125+
126+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
127+
128+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
129+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
130+
expect(writeFileSpy).toHaveBeenCalledWith(
131+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
132+
'{}',
133+
);
134+
expect(writeFileSpy).not.toHaveBeenCalledWith(
135+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
136+
'',
137+
);
138+
});
139+
});
140+
89141
describe('initializationError', () => {
90142
it('should throw initializationError when running commands', async () => {
91143
const error = new Error('Not installed');

packages/angular/cli/src/package-managers/testing/mock-host.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,27 +51,36 @@ export class MockHost implements Host {
5151
} as Stats);
5252
}
5353

54-
runCommand(): Promise<{ stdout: string; stderr: string }> {
54+
runCommand(
55+
command: string,
56+
args: readonly string[],
57+
options?: {
58+
timeout?: number;
59+
stdio?: 'pipe' | 'ignore';
60+
cwd?: string;
61+
env?: Record<string, string>;
62+
},
63+
): Promise<{ stdout: string; stderr: string }> {
5564
throw new Error('Method not implemented.');
5665
}
5766

58-
createTempDirectory(): Promise<string> {
67+
createTempDirectory(baseDir?: string): Promise<string> {
5968
throw new Error('Method not implemented.');
6069
}
6170

62-
deleteDirectory(): Promise<void> {
71+
deleteDirectory(path: string): Promise<void> {
6372
throw new Error('Method not implemented.');
6473
}
6574

66-
writeFile(): Promise<void> {
75+
writeFile(path: string, content: string): Promise<void> {
6776
throw new Error('Method not implemented.');
6877
}
6978

70-
readFile(): Promise<string> {
79+
readFile(path: string): Promise<string> {
7180
throw new Error('Method not implemented.');
7281
}
7382

74-
copyFile(): Promise<void> {
83+
copyFile(src: string, dest: string): Promise<void> {
7584
throw new Error('Method not implemented.');
7685
}
7786
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { createProjectFromAsset } from '../../utils/assets';
2+
import { readFile, writeFile } from '../../utils/fs';
3+
import { getActivePackageManager } from '../../utils/packages';
4+
import { ng } from '../../utils/process';
5+
6+
export default async function () {
7+
if (getActivePackageManager() !== 'pnpm') {
8+
return;
9+
}
10+
11+
let restoreRegistry: (() => Promise<void>) | undefined;
12+
13+
try {
14+
// Setup project from older asset using the public registry
15+
restoreRegistry = await createProjectFromAsset('20.0-project', true);
16+
17+
// Create pnpm-workspace.yaml inside the project directory
18+
await writeFile('pnpm-workspace.yaml', "packages:\n - '.'\n");
19+
20+
// Run ng update on @angular/cli to trigger the update from version 20 to the next major version
21+
await ng('update', '@angular/cli@21', '@angular/core@21');
22+
23+
// Verify that the pnpm lockfile does not contain references to the temporary package directory
24+
const lockfileContent = await readFile('pnpm-lock.yaml');
25+
if (lockfileContent.includes('angular-cli-tmp-packages-')) {
26+
throw new Error(
27+
'pnpm-lock.yaml contains reference to temporary package directory, isolation failed!',
28+
);
29+
}
30+
} finally {
31+
await restoreRegistry?.();
32+
}
33+
}

0 commit comments

Comments
 (0)