Skip to content

Commit 16c44a7

Browse files
committed
fix(@angular/cli): copy packageManager field and yarn config for temp installs
When installing a temporary CLI package during update command execution, the active packageManager field value is copied from the project to the temporary package.json to establish a local version boundary for Corepack and prevent upward directory traversal. Additionally, configuration copying is enabled for Yarn to copy project-level .yarnrc.yml/.yarnrc.yaml files so custom registries and credentials are preserved.
1 parent e5fff87 commit 16c44a7

3 files changed

Lines changed: 169 additions & 4 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
208208
noLockfileFlag: '',
209209
ignoreScriptsFlag: '--mode=skip-build',
210210
configFiles: ['.yarnrc.yml', '.yarnrc.yaml'],
211+
copyConfigFromProject: true,
211212
getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }),
212213
versionCommand: ['--version'],
213214
listDependenciesCommand: ['info', '--name-only', '--json'],

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

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -689,8 +689,21 @@ export class PackageManager {
689689

690690
// Some package managers, like yarn classic, do not write a package.json when adding a package.
691691
// This can cause issues with subsequent `require.resolve` calls.
692-
// Writing an empty package.json file beforehand prevents this.
693-
await this.host.writeFile(join(workingDirectory, 'package.json'), '{}');
692+
// Writing a package.json file beforehand prevents this. To ensure corepack
693+
// resolves the correct package manager version, copy the project's packageManager field.
694+
let packageManagerVersion: string | undefined;
695+
try {
696+
packageManagerVersion = await this.getVersion();
697+
} catch {
698+
// Ignore version lookup errors.
699+
}
700+
const tempPackageJson = packageManagerVersion
701+
? { packageManager: `${this.name}@${packageManagerVersion}` }
702+
: {};
703+
await this.host.writeFile(
704+
join(workingDirectory, 'package.json'),
705+
JSON.stringify(tempPackageJson, null, 2),
706+
);
694707

695708
// To prevent pnpm from traversing up the directory tree and modifying the project's workspace lockfile,
696709
// copy the project's `pnpm-workspace.yaml` (excluding monorepo local overrides/packages)
@@ -711,12 +724,16 @@ export class PackageManager {
711724
}
712725
}
713726

714-
// Copy configuration files if the package manager requires it (e.g., bun).
727+
// Copy configuration files if the package manager requires it (e.g., bun, yarn).
715728
if (this.descriptor.copyConfigFromProject) {
716729
for (const configFile of this.descriptor.configFiles) {
717730
try {
718731
const configPath = join(this.cwd, configFile);
719-
await this.host.copyFile(configPath, join(workingDirectory, configFile));
732+
let content = await this.host.readFile(configPath);
733+
if (this.name === 'yarn') {
734+
content = sanitizeYarnRc(content);
735+
}
736+
await this.host.writeFile(join(workingDirectory, configFile), content);
720737
} catch {
721738
// Ignore missing config files.
722739
}
@@ -829,3 +846,50 @@ function sanitizePnpmWorkspace(content: string): string {
829846

830847
return result.join('\n');
831848
}
849+
850+
/**
851+
* Sanitizes the contents of `.yarnrc.yml` or `.yarnrc.yaml` for temporary package installation.
852+
* It removes `yarnPath`, `plugins`, and `nodeLinker` fields, and appends `nodeLinker: node-modules`.
853+
* @param content The original Yarn configuration content.
854+
* @returns The sanitized Yarn configuration content.
855+
*/
856+
function sanitizeYarnRc(content: string): string {
857+
const lines = content.split(/\r?\n/);
858+
const result: string[] = [];
859+
let inBlockToRemove = false;
860+
let blockIndent = 0;
861+
862+
for (const line of lines) {
863+
const trimmed = line.trim();
864+
if (!trimmed) {
865+
result.push(line);
866+
continue;
867+
}
868+
869+
const indent = line.length - line.trimStart().length;
870+
871+
if (inBlockToRemove) {
872+
if (indent > blockIndent) {
873+
continue;
874+
}
875+
inBlockToRemove = false;
876+
}
877+
878+
if (
879+
indent === 0 &&
880+
(trimmed.startsWith('yarnPath:') ||
881+
trimmed.startsWith('nodeLinker:') ||
882+
trimmed.startsWith('plugins:'))
883+
) {
884+
inBlockToRemove = true;
885+
blockIndent = indent;
886+
continue;
887+
}
888+
889+
result.push(line);
890+
}
891+
892+
result.push('nodeLinker: node-modules');
893+
894+
return result.join('\n');
895+
}

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,106 @@ describe('PackageManager', () => {
193193
'',
194194
);
195195
});
196+
197+
it('should copy and sanitize .yarnrc.yml when package manager is yarn and it exists', async () => {
198+
const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn'];
199+
const testHost = new MockHost({
200+
'/tmp/project/node_modules': true,
201+
'/tmp/project/.yarnrc.yml': [],
202+
});
203+
const pm = new PackageManager(testHost, '/tmp/project', yarnDescriptor);
204+
205+
const createTempDirectorySpy = spyOn(testHost, 'createTempDirectory').and.resolveTo(
206+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
207+
);
208+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
209+
const mockYarnRcContent = [
210+
'yarnPath: .yarn/releases/yarn-4.4.1.cjs',
211+
'nodeLinker: pnp',
212+
'customOption:',
213+
' nodeLinker: pnp-nested',
214+
' plugins:',
215+
' - nested-plugin',
216+
'plugins:',
217+
' - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs',
218+
' spec: "@yarnpkg/plugin-typescript"',
219+
'npmRegistryServer: "https://registry.npmjs.org"',
220+
].join('\n');
221+
222+
spyOn(testHost, 'readFile').and.callFake(async (filePath) => {
223+
if (filePath.replace(/\\/g, '/').endsWith('.yarnrc.yml')) {
224+
return mockYarnRcContent;
225+
}
226+
if (filePath.replace(/\\/g, '/').endsWith('package.json')) {
227+
return JSON.stringify({ packageManager: 'yarn@4.4.1' });
228+
}
229+
throw new Error(`Unexpected readFile call for ${filePath}`);
230+
});
231+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '4.4.1', stderr: '' });
232+
233+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
234+
235+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
236+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
237+
238+
const expectedYarnRcContent = [
239+
'customOption:',
240+
' nodeLinker: pnp-nested',
241+
' plugins:',
242+
' - nested-plugin',
243+
'npmRegistryServer: "https://registry.npmjs.org"',
244+
'nodeLinker: node-modules',
245+
].join('\n');
246+
247+
expect(writeFileSpy).toHaveBeenCalledWith(
248+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml',
249+
expectedYarnRcContent,
250+
);
251+
expect(writeFileSpy).toHaveBeenCalledWith(
252+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
253+
JSON.stringify({ packageManager: 'yarn@4.4.1' }, null, 2),
254+
);
255+
});
256+
257+
it('should copy packageManager field to temp package.json if version is resolved', async () => {
258+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
259+
const testHost = new MockHost({
260+
'/tmp/project/node_modules': true,
261+
});
262+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
263+
264+
spyOn(testHost, 'createTempDirectory').and.resolveTo(
265+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
266+
);
267+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
268+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '10.32.1', stderr: '' });
269+
270+
await pm.acquireTempPackage('foo@1.0.0');
271+
272+
expect(writeFileSpy).toHaveBeenCalledWith(
273+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
274+
JSON.stringify({ packageManager: 'npm@10.32.1' }, null, 2),
275+
);
276+
});
277+
278+
it('should write empty package.json if package manager version cannot be resolved', async () => {
279+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
280+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
281+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
282+
283+
spyOn(testHost, 'createTempDirectory').and.resolveTo(
284+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
285+
);
286+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
287+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
288+
289+
await pm.acquireTempPackage('foo@1.0.0');
290+
291+
expect(writeFileSpy).toHaveBeenCalledWith(
292+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
293+
'{}',
294+
);
295+
});
196296
});
197297

198298
describe('getRegistryMetadata', () => {

0 commit comments

Comments
 (0)