diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index c53dcce4bb5d..cad01202462d 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -208,6 +208,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { noLockfileFlag: '', ignoreScriptsFlag: '--mode=skip-build', configFiles: ['.yarnrc.yml', '.yarnrc.yaml'], + copyConfigFromProject: true, getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }), versionCommand: ['--version'], listDependenciesCommand: ['info', '--name-only', '--json'], diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 686bfa617ca8..32f63e51deac 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -689,8 +689,21 @@ export class PackageManager { // Some package managers, like yarn classic, do not write a package.json when adding a package. // This can cause issues with subsequent `require.resolve` calls. - // Writing an empty package.json file beforehand prevents this. - await this.host.writeFile(join(workingDirectory, 'package.json'), '{}'); + // Writing a package.json file beforehand prevents this. To ensure corepack + // resolves the correct package manager version, copy the project's packageManager field. + let packageManagerVersion: string | undefined; + try { + packageManagerVersion = await this.getVersion(); + } catch { + // Ignore version lookup errors. + } + const tempPackageJson = packageManagerVersion + ? { packageManager: `${this.name}@${packageManagerVersion}` } + : {}; + await this.host.writeFile( + join(workingDirectory, 'package.json'), + JSON.stringify(tempPackageJson, null, 2), + ); // 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) @@ -711,12 +724,16 @@ export class PackageManager { } } - // Copy configuration files if the package manager requires it (e.g., bun). + // Copy configuration files if the package manager requires it (e.g., bun, yarn). if (this.descriptor.copyConfigFromProject) { for (const configFile of this.descriptor.configFiles) { try { const configPath = join(this.cwd, configFile); - await this.host.copyFile(configPath, join(workingDirectory, configFile)); + let content = await this.host.readFile(configPath); + if (this.name === 'yarn') { + content = sanitizeYarnRc(content); + } + await this.host.writeFile(join(workingDirectory, configFile), content); } catch { // Ignore missing config files. } @@ -829,3 +846,50 @@ function sanitizePnpmWorkspace(content: string): string { return result.join('\n'); } + +/** + * Sanitizes the contents of `.yarnrc.yml` or `.yarnrc.yaml` for temporary package installation. + * It removes `yarnPath`, `plugins`, and `nodeLinker` fields, and appends `nodeLinker: node-modules`. + * @param content The original Yarn configuration content. + * @returns The sanitized Yarn configuration content. + */ +function sanitizeYarnRc(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; + } + + const indent = line.length - line.trimStart().length; + + if (inBlockToRemove) { + if (indent > blockIndent) { + continue; + } + inBlockToRemove = false; + } + + if ( + indent === 0 && + (trimmed.startsWith('yarnPath:') || + trimmed.startsWith('nodeLinker:') || + trimmed.startsWith('plugins:')) + ) { + inBlockToRemove = true; + blockIndent = indent; + continue; + } + + result.push(line); + } + + result.push('nodeLinker: node-modules'); + + 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 7cf5657e53b6..83709af7182b 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -193,6 +193,106 @@ describe('PackageManager', () => { '', ); }); + + it('should copy and sanitize .yarnrc.yml when package manager is yarn and it exists', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const testHost = new MockHost({ + '/tmp/project/node_modules': true, + '/tmp/project/.yarnrc.yml': [], + }); + const pm = new PackageManager(testHost, '/tmp/project', yarnDescriptor); + + const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc', + ); + const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo(); + const mockYarnRcContent = [ + 'yarnPath: .yarn/releases/yarn-4.4.1.cjs', + 'nodeLinker: pnp', + 'customOption:', + ' nodeLinker: pnp-nested', + ' plugins:', + ' - nested-plugin', + 'plugins:', + ' - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs', + ' spec: "@yarnpkg/plugin-typescript"', + 'npmRegistryServer: "https://registry.npmjs.org"', + ].join('\n'); + + spyOn(testHost, 'readFile').and.callFake(async (filePath) => { + if (filePath.replace(/\\/g, '/').endsWith('.yarnrc.yml')) { + return mockYarnRcContent; + } + if (filePath.replace(/\\/g, '/').endsWith('package.json')) { + return JSON.stringify({ packageManager: 'yarn@4.4.1' }); + } + throw new Error(`Unexpected readFile call for ${filePath}`); + }); + spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '4.4.1', 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'); + + const expectedYarnRcContent = [ + 'customOption:', + ' nodeLinker: pnp-nested', + ' plugins:', + ' - nested-plugin', + 'npmRegistryServer: "https://registry.npmjs.org"', + 'nodeLinker: node-modules', + ].join('\n'); + + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml', + expectedYarnRcContent, + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + JSON.stringify({ packageManager: 'yarn@4.4.1' }, null, 2), + ); + }); + + it('should copy packageManager field to temp package.json if version is resolved', async () => { + const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; + const testHost = new MockHost({ + '/tmp/project/node_modules': true, + }); + const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor); + + 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: '10.32.1', stderr: '' }); + + await pm.acquireTempPackage('foo@1.0.0'); + + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + JSON.stringify({ packageManager: 'npm@10.32.1' }, null, 2), + ); + }); + + it('should write empty package.json if package manager version cannot be resolved', async () => { + const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; + const testHost = new MockHost({ '/tmp/project/node_modules': true }); + const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor); + + 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: '' }); + + await pm.acquireTempPackage('foo@1.0.0'); + + expect(writeFileSpy).toHaveBeenCalledWith( + '/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json', + '{}', + ); + }); }); describe('getRegistryMetadata', () => {