Skip to content

Commit 203eec1

Browse files
committed
fix(@angular/cli): isolate temporary package installations from parent Yarn workspace
When installing a temporary CLI package during update command execution, the temporary directory is placed within the project tree (e.g. under node_modules). When using Yarn modern (v2+), Yarn searches upward for a yarn.lock file to locate the project root. Upon finding the parent project's yarn.lock, Yarn detects that the temporary directory contains a package.json but is not a declared workspace member, causing yarn add to fail with a Usage Error. To resolve this, an empty yarn.lock file is written to the temporary directory when the package manager is Yarn modern. This creates a project boundary that prevents upward workspace traversal and ensures Yarn treats the directory as an independent project. Additionally, if the parent project does not define a .yarnrc.yml or .yarnrc.yaml, a default configuration specifying 'nodeLinker: node-modules' is written to ensure packages are installed into node_modules rather than PnP. Fixes #33972
1 parent ab6d7df commit 203eec1

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,20 +724,35 @@ export class PackageManager {
724724
}
725725
}
726726

727+
// To prevent Yarn modern from traversing up the directory tree and failing because the temporary
728+
// directory is not part of the project's workspace, write an empty `yarn.lock` to act as a project boundary.
729+
if (this.name === 'yarn') {
730+
await this.host.writeFile(join(workingDirectory, 'yarn.lock'), '');
731+
}
732+
727733
// Copy configuration files if the package manager requires it (e.g., bun, yarn).
728734
if (this.descriptor.copyConfigFromProject) {
735+
let copiedYarnConfig = false;
729736
for (const configFile of this.descriptor.configFiles) {
730737
try {
731738
const configPath = join(this.cwd, configFile);
732739
let content = await this.host.readFile(configPath);
733740
if (this.name === 'yarn') {
734741
content = sanitizeYarnRc(content);
742+
copiedYarnConfig = true;
735743
}
736744
await this.host.writeFile(join(workingDirectory, configFile), content);
737745
} catch {
738746
// Ignore missing config files.
739747
}
740748
}
749+
750+
if (this.name === 'yarn' && !copiedYarnConfig) {
751+
await this.host.writeFile(
752+
join(workingDirectory, '.yarnrc.yml'),
753+
'nodeLinker: node-modules\n',
754+
);
755+
}
741756
}
742757

743758
const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter(

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ describe('PackageManager', () => {
192192
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/pnpm-workspace.yaml',
193193
'',
194194
);
195+
expect(writeFileSpy).not.toHaveBeenCalledWith(
196+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock',
197+
'',
198+
);
195199
});
196200

197201
it('should copy and sanitize .yarnrc.yml when package manager is yarn and it exists', async () => {
@@ -244,6 +248,10 @@ describe('PackageManager', () => {
244248
'nodeLinker: node-modules',
245249
].join('\n');
246250

251+
expect(writeFileSpy).toHaveBeenCalledWith(
252+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock',
253+
'',
254+
);
247255
expect(writeFileSpy).toHaveBeenCalledWith(
248256
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml',
249257
expectedYarnRcContent,
@@ -254,6 +262,45 @@ describe('PackageManager', () => {
254262
);
255263
});
256264

265+
it('should write empty yarn.lock and default .yarnrc.yml when package manager is yarn and config does not exist', async () => {
266+
const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn'];
267+
const testHost = new MockHost({
268+
'/tmp/project/node_modules': true,
269+
});
270+
const pm = new PackageManager(testHost, '/tmp/project', yarnDescriptor);
271+
272+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
273+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
274+
);
275+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
276+
277+
spyOn(testHost, 'readFile').and.callFake(async (filePath) => {
278+
if (filePath.replace(/\\/g, '/').endsWith('package.json')) {
279+
return JSON.stringify({ packageManager: 'yarn@4.4.1' });
280+
}
281+
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
282+
});
283+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '4.4.1', stderr: '' });
284+
285+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
286+
287+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
288+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
289+
290+
expect(writeFileSpy).toHaveBeenCalledWith(
291+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/yarn.lock',
292+
'',
293+
);
294+
expect(writeFileSpy).toHaveBeenCalledWith(
295+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml',
296+
'nodeLinker: node-modules\n',
297+
);
298+
expect(writeFileSpy).toHaveBeenCalledWith(
299+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
300+
JSON.stringify({ packageManager: 'yarn@4.4.1' }, null, 2),
301+
);
302+
});
303+
257304
it('should copy packageManager field to temp package.json if version is resolved', async () => {
258305
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
259306
const testHost = new MockHost({

0 commit comments

Comments
 (0)