Skip to content

Commit 330ac84

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 330ac84

4 files changed

Lines changed: 118 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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,70 @@ 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 pm = new PackageManager(host, '/tmp/project', pnpmDescriptor);
93+
94+
const createTempDirectorySpy = spyOn(host, 'createTempDirectory').and.resolveTo(
95+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
96+
);
97+
const writeFileSpy = spyOn(host, 'writeFile').and.resolveTo();
98+
spyOn(host, 'stat').and.callFake(async (path) => {
99+
if (path === '/tmp/project/node_modules') {
100+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101+
return { isDirectory: () => true, isFile: () => false } as any;
102+
}
103+
throw new Error('Not found');
104+
});
105+
runCommandSpy.and.resolveTo({ stdout: '', stderr: '' });
106+
107+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
108+
109+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
110+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
111+
expect(writeFileSpy).toHaveBeenCalledWith(
112+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
113+
'{}',
114+
);
115+
expect(writeFileSpy).toHaveBeenCalledWith(
116+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
117+
'',
118+
);
119+
});
120+
121+
it('should NOT write pnpm-workspace.yaml when package manager is npm', async () => {
122+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
123+
const pm = new PackageManager(host, '/tmp/project', npmDescriptor);
124+
125+
const createTempDirectorySpy = spyOn(host, 'createTempDirectory').and.resolveTo(
126+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
127+
);
128+
const writeFileSpy = spyOn(host, 'writeFile').and.resolveTo();
129+
spyOn(host, 'stat').and.callFake(async (path) => {
130+
if (path === '/tmp/project/node_modules') {
131+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
132+
return { isDirectory: () => true, isFile: () => false } as any;
133+
}
134+
throw new Error('Not found');
135+
});
136+
runCommandSpy.and.resolveTo({ stdout: '', stderr: '' });
137+
138+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
139+
140+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
141+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
142+
expect(writeFileSpy).toHaveBeenCalledWith(
143+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
144+
'{}',
145+
);
146+
expect(writeFileSpy).not.toHaveBeenCalledWith(
147+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
148+
'',
149+
);
150+
});
151+
});
152+
89153
describe('initializationError', () => {
90154
it('should throw initializationError when running commands', async () => {
91155
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 current version
21+
await ng('update', '@angular/cli');
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)