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
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
72 changes: 68 additions & 4 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
}
Expand Down Expand Up @@ -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:'))
) {
Comment thread
clydin marked this conversation as resolved.
inBlockToRemove = true;
blockIndent = indent;
continue;
}

result.push(line);
}

result.push('nodeLinker: node-modules');

return result.join('\n');
}
100 changes: 100 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 @@ -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', () => {
Expand Down
Loading