Skip to content

Commit e85c10c

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. (cherry picked from commit 3789cee)
1 parent 5584a58 commit e85c10c

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
@@ -197,6 +197,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
197197
noLockfileFlag: '',
198198
ignoreScriptsFlag: '--mode=skip-build',
199199
configFiles: ['.yarnrc.yml', '.yarnrc.yaml'],
200+
copyConfigFromProject: true,
200201
getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }),
201202
versionCommand: ['--version'],
202203
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
@@ -688,8 +688,21 @@ export class PackageManager {
688688

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

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

713-
// Copy configuration files if the package manager requires it (e.g., bun).
726+
// Copy configuration files if the package manager requires it (e.g., bun, yarn).
714727
if (this.descriptor.copyConfigFromProject) {
715728
for (const configFile of this.descriptor.configFiles) {
716729
try {
717730
const configPath = join(this.cwd, configFile);
718-
await this.host.copyFile(configPath, join(workingDirectory, configFile));
731+
let content = await this.host.readFile(configPath);
732+
if (this.name === 'yarn') {
733+
content = sanitizeYarnRc(content);
734+
}
735+
await this.host.writeFile(join(workingDirectory, configFile), content);
719736
} catch {
720737
// Ignore missing config files.
721738
}
@@ -796,3 +813,50 @@ function sanitizePnpmWorkspace(content: string): string {
796813

797814
return result.join('\n');
798815
}
816+
817+
/**
818+
* Sanitizes the contents of `.yarnrc.yml` or `.yarnrc.yaml` for temporary package installation.
819+
* It removes `yarnPath`, `plugins`, and `nodeLinker` fields, and appends `nodeLinker: node-modules`.
820+
* @param content The original Yarn configuration content.
821+
* @returns The sanitized Yarn configuration content.
822+
*/
823+
function sanitizeYarnRc(content: string): string {
824+
const lines = content.split(/\r?\n/);
825+
const result: string[] = [];
826+
let inBlockToRemove = false;
827+
let blockIndent = 0;
828+
829+
for (const line of lines) {
830+
const trimmed = line.trim();
831+
if (!trimmed) {
832+
result.push(line);
833+
continue;
834+
}
835+
836+
const indent = line.length - line.trimStart().length;
837+
838+
if (inBlockToRemove) {
839+
if (indent > blockIndent) {
840+
continue;
841+
}
842+
inBlockToRemove = false;
843+
}
844+
845+
if (
846+
indent === 0 &&
847+
(trimmed.startsWith('yarnPath:') ||
848+
trimmed.startsWith('nodeLinker:') ||
849+
trimmed.startsWith('plugins:'))
850+
) {
851+
inBlockToRemove = true;
852+
blockIndent = indent;
853+
continue;
854+
}
855+
856+
result.push(line);
857+
}
858+
859+
result.push('nodeLinker: node-modules');
860+
861+
return result.join('\n');
862+
}

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)