Skip to content

Commit ff8fe33

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 ff8fe33

3 files changed

Lines changed: 160 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: 67 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,49 @@ 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+
trimmed.startsWith('yarnPath:') ||
880+
trimmed.startsWith('nodeLinker:') ||
881+
trimmed.startsWith('plugins:')
882+
) {
883+
inBlockToRemove = true;
884+
blockIndent = indent;
885+
continue;
886+
}
887+
888+
result.push(line);
889+
}
890+
891+
result.push('nodeLinker: node-modules');
892+
893+
return result.join('\n');
894+
}

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,98 @@ 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+
'plugins:',
213+
' - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs',
214+
' spec: "@yarnpkg/plugin-typescript"',
215+
'npmRegistryServer: "https://registry.npmjs.org"',
216+
].join('\n');
217+
218+
spyOn(testHost, 'readFile').and.callFake(async (filePath) => {
219+
if (filePath.replace(/\\/g, '/').endsWith('.yarnrc.yml')) {
220+
return mockYarnRcContent;
221+
}
222+
if (filePath.replace(/\\/g, '/').endsWith('package.json')) {
223+
return JSON.stringify({ packageManager: 'yarn@4.4.1' });
224+
}
225+
throw new Error(`Unexpected readFile call for ${filePath}`);
226+
});
227+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '4.4.1', stderr: '' });
228+
229+
const { workingDirectory } = await pm.acquireTempPackage('foo@1.0.0');
230+
231+
expect(workingDirectory).toBe('/tmp/project/node_modules/angular-cli-tmp-packages-abc');
232+
expect(createTempDirectorySpy).toHaveBeenCalledWith('/tmp/project/node_modules');
233+
234+
const expectedYarnRcContent = [
235+
'npmRegistryServer: "https://registry.npmjs.org"',
236+
'nodeLinker: node-modules',
237+
].join('\n');
238+
239+
expect(writeFileSpy).toHaveBeenCalledWith(
240+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/.yarnrc.yml',
241+
expectedYarnRcContent,
242+
);
243+
expect(writeFileSpy).toHaveBeenCalledWith(
244+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
245+
JSON.stringify({ packageManager: 'yarn@4.4.1' }, null, 2),
246+
);
247+
});
248+
249+
it('should copy packageManager field to temp package.json if version is resolved', async () => {
250+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
251+
const testHost = new MockHost({
252+
'/tmp/project/node_modules': true,
253+
});
254+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
255+
256+
spyOn(testHost, 'createTempDirectory').and.resolveTo(
257+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
258+
);
259+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
260+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '10.32.1', stderr: '' });
261+
262+
await pm.acquireTempPackage('foo@1.0.0');
263+
264+
expect(writeFileSpy).toHaveBeenCalledWith(
265+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
266+
JSON.stringify({ packageManager: 'npm@10.32.1' }, null, 2),
267+
);
268+
});
269+
270+
it('should write empty package.json if package manager version cannot be resolved', async () => {
271+
const npmDescriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
272+
const testHost = new MockHost({ '/tmp/project/node_modules': true });
273+
const pm = new PackageManager(testHost, '/tmp/project', npmDescriptor);
274+
275+
spyOn(testHost, 'createTempDirectory').and.resolveTo(
276+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc',
277+
);
278+
const writeFileSpy = spyOn(testHost, 'writeFile').and.resolveTo();
279+
spyOn(testHost, 'runCommand').and.resolveTo({ stdout: '', stderr: '' });
280+
281+
await pm.acquireTempPackage('foo@1.0.0');
282+
283+
expect(writeFileSpy).toHaveBeenCalledWith(
284+
'/tmp/project/node_modules/angular-cli-tmp-packages-abc/package.json',
285+
'{}',
286+
);
287+
});
196288
});
197289

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

0 commit comments

Comments
 (0)