From 6b032576648c038e39f27f9bd4b65f198044bed3 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:47:20 -0400 Subject: [PATCH] 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. --- .../src/package-managers/package-manager.ts | 77 +++++++++++++ .../package-managers/package-manager_spec.ts | 109 ++++++++++++++++++ .../src/package-managers/testing/mock-host.ts | 21 +++- .../e2e/tests/update/update-pnpm-workspace.ts | 33 ++++++ 4 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/tests/update/update-pnpm-workspace.ts diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 33b8b07d48e3..46adbd118b9b 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -647,6 +647,25 @@ export class PackageManager { // Writing an empty package.json file beforehand prevents this. await this.host.writeFile(join(workingDirectory, 'package.json'), '{}'); + // To prevent pnpm from traversing up the directory tree and modifying the project's workspace lockfile, + // copy the project's `pnpm-workspace.yaml` (excluding monorepo local overrides/packages) + // to the temporary directory if it exists, or write an empty one to act as a workspace boundary. + if (this.name === 'pnpm') { + try { + const workspaceConfigPath = join(this.cwd, 'pnpm-workspace.yaml'); + const content = await this.host.readFile(workspaceConfigPath); + await this.host.writeFile( + join(workingDirectory, 'pnpm-workspace.yaml'), + sanitizePnpmWorkspace(content), + ); + } catch { + await this.host.writeFile( + join(workingDirectory, 'pnpm-workspace.yaml'), + "packages:\n - '.'\n", + ); + } + } + // Copy configuration files if the package manager requires it (e.g., bun). if (this.descriptor.copyConfigFromProject) { for (const configFile of this.descriptor.configFiles) { @@ -675,3 +694,61 @@ export class PackageManager { return { workingDirectory, cleanup }; } } + +/** + * Sanitizes a `pnpm-workspace.yaml` file content to be safely used inside + * a temporary installation directory. + * + * This function removes monorepo-specific settings that would fail or cause + * unintended behaviors in a standalone, temporary project environment: + * 1. Removes the `overrides:` block, as it may contain `workspace:` protocol + * resolutions which cannot be resolved in a standalone folder. + * 2. Rewrites the `packages:` block to include only the current directory (`'.'`), + * isolating the temporary installation from other projects in the monorepo. + * + * All other settings (such as `minimumReleaseAge`, package extensions, etc.) + * are preserved intact. + * + * @param content The original `pnpm-workspace.yaml` content. + * @returns The sanitized YAML content. + */ +function sanitizePnpmWorkspace(content: string): string { + const lines = content.split(/\r?\n/); + const result: string[] = []; + let inBlockToRemove = false; + let blockIndent = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + result.push(line); + continue; + } + + // Determine the current line's indentation level. + const indent = line.length - line.trimStart().length; + + // If we are currently parsing a block to remove, skip any lines with larger indentation. + if (inBlockToRemove) { + if (indent > blockIndent) { + continue; + } + inBlockToRemove = false; + } + + // Identify blocks we want to remove or customize (e.g. 'overrides' or 'packages'). + if (trimmed.startsWith('overrides:') || trimmed.startsWith('packages:')) { + inBlockToRemove = true; + blockIndent = indent; + if (trimmed.startsWith('packages:')) { + // Replace packages list to restrict it strictly to the current standalone folder. + result.push(line.replace(/packages:.*/, "packages:\n - '.'")); + } + continue; + } + + result.push(line); + } + + return result.join('\n'); +} diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 8d439d9b3b75..176b604eeca6 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -86,6 +86,115 @@ describe('PackageManager', () => { }); }); + describe('acquireTempPackage', () => { + it('should copy and sanitize pnpm-workspace.yaml when package manager is pnpm and workspace file exists', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const testHost = new MockHost({ '/tmp/project/node_modules': true }); + const pm = new PackageManager(testHost, '/tmp/project', pnpmDescriptor); + + const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc', + ); + const mockWorkspaceContent = [ + 'packages:', + ' - .', + ' - packages/*', + 'minimumReleaseAge: 1440', + 'minimumReleaseAgeExclude:', + " - '@angular/*'", + 'overrides:', + " '@angular/build': workspace:*", + 'packageExtensions:', + ' vitest:', + ' peerDependencies:', + " '@vitest/coverage-v8': '*'", + ].join('\n'); + const readFileSpy = spyOn(testHost, 'readFile').and.resolveTo(mockWorkspaceContent); + const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo(); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' }); + + const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0'); + + expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc'); + expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules'); + expect(readFileSpy).toHaveBeenCalledWith('/tmp/project/pnpm-workspace.yaml'); + + const expectedSanitizedContent = [ + 'packages:', + " - '.'", + 'minimumReleaseAge: 1440', + 'minimumReleaseAgeExclude:', + " - '@angular/*'", + 'packageExtensions:', + ' vitest:', + ' peerDependencies:', + " '@vitest/coverage-v8': '*'", + ].join('\n'); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml', + expectedSanitizedContent, + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + '{}', + ); + }); + + it('should write empty pnpm-workspace.yaml as fallback when package manager is pnpm and workspace file does not exist', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const testHost = new MockHost({ '/tmp/project/node_modules': true }); + const pm = new PackageManager(testHost, '/tmp/project', pnpmDescriptor); + + const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc', + ); + const readFileSpy = spyOn(testHost, 'readFile').and.throwError( + new Error('ENOENT: no such file or directory'), + ); + const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo(); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' }); + + const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0'); + + expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc'); + expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules'); + expect(readFileSpy).toHaveBeenCalledWith('/tmp/project/pnpm-workspace.yaml'); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + '{}', + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml', + "packages:\n - '.'\n", + ); + }); + + it('should NOT write pnpm-workspace.yaml when package manager is npm', async () => { + const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; + const testHost = new MockHost({ '/tmp/project/node_modules': true }); + const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor); + + const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc', + ); + const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo(); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' }); + + const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0'); + + expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc'); + expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules'); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + '{}', + ); + expect(writeFileSpy).not.toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml', + '', + ); + }); + }); + describe('initializationError', () => { it('should throw initializationError when running commands', async () => { const error = new Error('Not installed'); diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts index 46e71be3cf60..9051cfae3b04 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -51,27 +51,36 @@ export class MockHost implements Host { } as Stats); } - runCommand(): Promise<{ stdout: string; stderr: string }> { + runCommand( + command: string, + args: readonly string[], + options?: { + timeout?: number; + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + }, + ): Promise<{ stdout: string; stderr: string }> { throw new Error('Method not implemented.'); } - createTempDirectory(): Promise { + createTempDirectory(baseDir?: string): Promise { throw new Error('Method not implemented.'); } - deleteDirectory(): Promise { + deleteDirectory(path: string): Promise { throw new Error('Method not implemented.'); } - writeFile(): Promise { + writeFile(path: string, content: string): Promise { throw new Error('Method not implemented.'); } - readFile(): Promise { + readFile(path: string): Promise { throw new Error('Method not implemented.'); } - copyFile(): Promise { + copyFile(src: string, dest: string): Promise { throw new Error('Method not implemented.'); } } diff --git a/tests/e2e/tests/update/update-pnpm-workspace.ts b/tests/e2e/tests/update/update-pnpm-workspace.ts new file mode 100644 index 000000000000..29aff7bff42a --- /dev/null +++ b/tests/e2e/tests/update/update-pnpm-workspace.ts @@ -0,0 +1,33 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { readFile, writeFile } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng } from '../../utils/process'; + +export default async function () { + if (getActivePackageManager() !== 'pnpm') { + return; + } + + let restoreRegistry: (() => Promise) | undefined; + + try { + // Setup project from older asset using the public registry + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // Create pnpm-workspace.yaml inside the project directory + await writeFile('pnpm-workspace.yaml', "packages:\n - '.'\n"); + + // Run ng update on @angular/cli to trigger the update from version 20 to the next major version + await ng('update', '@angular/cli@21', '@angular/core@21'); + + // Verify that the pnpm lockfile does not contain references to the temporary package directory + const lockfileContent = await readFile('pnpm-lock.yaml'); + if (lockfileContent.includes('angular-cli-tmp-packages-')) { + throw new Error( + 'pnpm-lock.yaml contains reference to temporary package directory, isolation failed!', + ); + } + } finally { + await restoreRegistry?.(); + } +}