Skip to content

Commit 167aea1

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 3f7494c commit 167aea1

4 files changed

Lines changed: 234 additions & 6 deletions

File tree

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,25 @@ 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+
// copy the project's `pnpm-workspace.yaml` (excluding monorepo local overrides/packages)
652+
// to the temporary directory if it exists, or write an empty one to act as a workspace boundary.
653+
if (this.name === 'pnpm') {
654+
try {
655+
const workspaceConfigPath = join(this.cwd, 'pnpm-workspace.yaml');
656+
const content = await this.host.readFile(workspaceConfigPath);
657+
await this.host.writeFile(
658+
join(workingDirectory, 'pnpm-workspace.yaml'),
659+
sanitizePnpmWorkspace(content),
660+
);
661+
} catch {
662+
await this.host.writeFile(
663+
join(workingDirectory, 'pnpm-workspace.yaml'),
664+
"packages:\n - '.'\n",
665+
);
666+
}
667+
}
668+
650669
// Copy configuration files if the package manager requires it (e.g., bun).
651670
if (this.descriptor.copyConfigFromProject) {
652671
for (const configFile of this.descriptor.configFiles) {
@@ -675,3 +694,61 @@ export class PackageManager {
675694
return { workingDirectory, cleanup };
676695
}
677696
}
697+
698+
/**
699+
* Sanitizes a `pnpm-workspace.yaml` file content to be safely used inside
700+
* a temporary installation directory.
701+
*
702+
* This function removes monorepo-specific settings that would fail or cause
703+
* unintended behaviors in a standalone, temporary project environment:
704+
* 1. Removes the `overrides:` block, as it may contain `workspace:` protocol
705+
* resolutions which cannot be resolved in a standalone folder.
706+
* 2. Rewrites the `packages:` block to include only the current directory (`'.'`),
707+
* isolating the temporary installation from other projects in the monorepo.
708+
*
709+
* All other settings (such as `minimumReleaseAge`, package extensions, etc.)
710+
* are preserved intact.
711+
*
712+
* @param content The original `pnpm-workspace.yaml` content.
713+
* @returns The sanitized YAML content.
714+
*/
715+
function sanitizePnpmWorkspace(content: string): string {
716+
const lines = content.split(/\r?\n/);
717+
const result: string[] = [];
718+
let inBlockToRemove = false;
719+
let blockIndent = 0;
720+
721+
for (const line of lines) {
722+
const trimmed = line.trim();
723+
if (!trimmed) {
724+
result.push(line);
725+
continue;
726+
}
727+
728+
// Determine the current line's indentation level.
729+
const indent = line.length - line.trimStart().length;
730+
731+
// If we are currently parsing a block to remove, skip any lines with larger indentation.
732+
if (inBlockToRemove) {
733+
if (indent > blockIndent) {
734+
continue;
735+
}
736+
inBlockToRemove = false;
737+
}
738+
739+
// Identify blocks we want to remove or customize (e.g. 'overrides' or 'packages').
740+
if (trimmed.startsWith('overrides:') || trimmed.startsWith('packages:')) {
741+
inBlockToRemove = true;
742+
blockIndent = indent;
743+
if (trimmed.startsWith('packages:')) {
744+
// Replace packages list to restrict it strictly to the current standalone folder.
745+
result.push(line.replace(/packages:.*/, "packages:\n - '.'"));
746+
}
747+
continue;
748+
}
749+
750+
result.push(line);
751+
}
752+
753+
return result.join('\n');
754+
}

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

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

89+
describe('acquireTempPackage', () => {
90+
it('should copy and sanitize pnpm-workspace.yaml when package manager is pnpm and workspace file exists', 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 mockWorkspaceContent = [
99+
'packages:',
100+
' - .',
101+
' - packages/*',
102+
'minimumReleaseAge: 1440',
103+
'minimumReleaseAgeExclude:',
104+
" - '@angular/*'",
105+
'overrides:',
106+
" '@angular/build': workspace:*",
107+
'packageExtensions:',
108+
' vitest:',
109+
' peerDependencies:',
110+
" '@vitest/coverage-v8': '*'",
111+
].join('\n');
112+
const readFileSpy = spyOn(testHost, 'readFile').and.resolveTo(mockWorkspaceContent);
113+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
114+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
115+
116+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
117+
118+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
119+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
120+
expect(readFileSpy).toHaveBeenCalledWith('/tmp/project/pnpm-workspace.yaml');
121+
122+
const expectedSanitizedContent = [
123+
'packages:',
124+
" - '.'",
125+
'minimumReleaseAge: 1440',
126+
'minimumReleaseAgeExclude:',
127+
" - '@angular/*'",
128+
'packageExtensions:',
129+
' vitest:',
130+
' peerDependencies:',
131+
" '@vitest/coverage-v8': '*'",
132+
].join('\n');
133+
expect(writeFileSpy).toHaveBeenCalledWith(
134+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
135+
expectedSanitizedContent,
136+
);
137+
expect(writeFileSpy).toHaveBeenCalledWith(
138+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
139+
'{}',
140+
);
141+
});
142+
143+
it('should write empty pnpm-workspace.yaml as fallback when package manager is pnpm and workspace file does not exist', async () => {
144+
const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm'];
145+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
146+
const pm = new PackageManager(testHost, '/tmp/project', pnpmDescriptor);
147+
148+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
149+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
150+
);
151+
const readFileSpy = spyOn(testHost, 'readFile').and.throwError(
152+
new Error('ENOENT: no such file or directory'),
153+
);
154+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
155+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
156+
157+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
158+
159+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
160+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
161+
expect(readFileSpy).toHaveBeenCalledWith('/tmp/project/pnpm-workspace.yaml');
162+
expect(writeFileSpy).toHaveBeenCalledWith(
163+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
164+
'{}',
165+
);
166+
expect(writeFileSpy).toHaveBeenCalledWith(
167+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
168+
"packages:\n - '.'\n",
169+
);
170+
});
171+
172+
it('should NOT write pnpm-workspace.yaml when package manager is npm', async () => {
173+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
174+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
175+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
176+
177+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
178+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
179+
);
180+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
181+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
182+
183+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
184+
185+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
186+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
187+
expect(writeFileSpy).toHaveBeenCalledWith(
188+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
189+
'{}',
190+
);
191+
expect(writeFileSpy).not.toHaveBeenCalledWith(
192+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
193+
'',
194+
);
195+
});
196+
});
197+
89198
describe('initializationError', () => {
90199
it('should throw initializationError when running commands', async () => {
91200
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)