Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
}
109 changes: 109 additions & 0 deletions packages/angular/cli/src/package-managers/package-manager_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
clydin marked this conversation as resolved.

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');
Expand Down
21 changes: 15 additions & 6 deletions packages/angular/cli/src/package-managers/testing/mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
},
): Promise<{ stdout: string; stderr: string }> {
throw new Error('Method not implemented.');
}

createTempDirectory(): Promise<string> {
createTempDirectory(baseDir?: string): Promise<string> {
throw new Error('Method not implemented.');
}

deleteDirectory(): Promise<void> {
deleteDirectory(path: string): Promise<void> {
throw new Error('Method not implemented.');
}

writeFile(): Promise<void> {
writeFile(path: string, content: string): Promise<void> {
throw new Error('Method not implemented.');
}

readFile(): Promise<string> {
readFile(path: string): Promise<string> {
throw new Error('Method not implemented.');
}

copyFile(): Promise<void> {
copyFile(src: string, dest: string): Promise<void> {
throw new Error('Method not implemented.');
}
}
33 changes: 33 additions & 0 deletions tests/e2e/tests/update/update-pnpm-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<void>) | 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?.();
}
}
Loading