Skip to content

Commit 0dc269f

Browse files
committed
fix(@angular/cli): abort and guide manual mitigation when updating catalog packages
When packages to be updated are configured to use catalogs (e.g. '@angular/core': 'catalog:'), ng update cannot modify the shared catalog configuration files directly without potentially breaking other monorepo projects. Detect catalog updates early in resolveUserUpdatePlan, resolve the target versions from the registry, and abort with a detailed step-by-step instruction on how to manually update their catalog config file, run package manager install, and execute migration schematics via 'ng update --migrate-only --from <installed-version>'.
1 parent b0b3a47 commit 0dc269f

2 files changed

Lines changed: 199 additions & 16 deletions

File tree

packages/angular/cli/src/commands/update/update-resolver.ts

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,23 @@ async function _buildPackageInfo(
541541
};
542542
}
543543

544+
function splitPackageName(pkg: string): { name: string; version?: string } {
545+
let name = pkg;
546+
let version: string | undefined;
547+
548+
if (pkg.startsWith('@')) {
549+
const parts = pkg.split('@');
550+
name = '@' + parts[1];
551+
version = parts[2];
552+
} else if (pkg.includes('@')) {
553+
const parts = pkg.split('@');
554+
name = parts[0];
555+
version = parts[1];
556+
}
557+
558+
return { name, version };
559+
}
560+
544561
function _buildPackageList(
545562
options: UpdateResolverOptions,
546563
allDependencies: ReadonlyMap<string, VersionRange>,
@@ -554,28 +571,18 @@ function _buildPackageList(
554571
}
555572

556573
for (const pkg of inputPackages) {
557-
let pkgName = pkg;
558-
let pkgVersion: string | undefined;
559-
560-
if (pkg.startsWith('@')) {
561-
const parts = pkg.split('@');
562-
pkgName = '@' + parts[1];
563-
pkgVersion = parts[2];
564-
} else if (pkg.includes('@')) {
565-
const parts = pkg.split('@');
566-
pkgName = parts[0];
567-
pkgVersion = parts[1];
568-
}
574+
const { name: pkgName, version: pkgVersion } = splitPackageName(pkg);
569575

570576
if (!allDependencies.has(pkgName)) {
571577
throw new Error(`Package ${JSON.stringify(pkgName)} is not in package.json.`);
572578
}
573579

574-
if (options.migrateOnly && !pkgVersion && options.from) {
575-
pkgVersion = options.from;
580+
let targetVersion = pkgVersion;
581+
if (options.migrateOnly && !targetVersion && options.from) {
582+
targetVersion = options.from;
576583
}
577584

578-
packages.set(pkgName, (pkgVersion || (options.next ? 'next' : 'latest')) as VersionRange);
585+
packages.set(pkgName, (targetVersion || (options.next ? 'next' : 'latest')) as VersionRange);
579586
}
580587

581588
return packages;
@@ -759,6 +766,74 @@ function isPkgFromRegistry(name: string, specifier: string): boolean {
759766
return !!result.registry;
760767
}
761768

769+
async function checkCatalogUpdates(
770+
normalizedPackages: string[],
771+
packageJsonContent: PackageManifest,
772+
registryClient: RegistryClient,
773+
workspaceRoot: string,
774+
options: UpdateResolverOptions,
775+
): Promise<void> {
776+
const catalogUpdates: { name: string; current: string; target: string; specifier: string }[] = [];
777+
778+
for (const requestedPkg of normalizedPackages) {
779+
const { name: pkgName } = splitPackageName(requestedPkg);
780+
const specifier =
781+
packageJsonContent.dependencies?.[pkgName] ||
782+
packageJsonContent.devDependencies?.[pkgName] ||
783+
packageJsonContent.peerDependencies?.[pkgName];
784+
785+
if (specifier?.startsWith('catalog:')) {
786+
const current = getInstalledVersion(pkgName, workspaceRoot) ?? 'unknown';
787+
let target = 'latest';
788+
try {
789+
const metadata = await registryClient.getMetadata(pkgName);
790+
if (metadata) {
791+
const resolved = await resolvePackageVersion(
792+
registryClient,
793+
metadata,
794+
options.next ? 'next' : 'latest',
795+
!!options.next,
796+
);
797+
target = resolved ?? 'latest';
798+
}
799+
} catch {
800+
// Fallback to 'latest' tag
801+
}
802+
803+
catalogUpdates.push({ name: pkgName, current, target, specifier });
804+
}
805+
}
806+
807+
if (catalogUpdates.length > 0) {
808+
const packageManagerName = options.packageManager ?? 'your package manager';
809+
const installCmd = packageManagerName === 'yarn' ? 'yarn install' : 'pnpm install';
810+
811+
const updatesList = catalogUpdates
812+
.map((pkg) => ` - ${pkg.name} (${pkg.specifier}) -> Target version: ${pkg.target}`)
813+
.join('\n');
814+
815+
const migrationCommands = catalogUpdates
816+
.map((pkg) => {
817+
const fromVer = pkg.current === 'unknown' ? '<current-version>' : pkg.current;
818+
819+
return ` ng update ${pkg.name} --migrate-only --from ${fromVer}`;
820+
})
821+
.join('\n');
822+
823+
throw new Error(
824+
`The following packages to update are configured to use catalogs:\n` +
825+
`${updatesList}\n\n` +
826+
`Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly.\n` +
827+
`Please perform the following steps to update:\n` +
828+
` 1. Manually update the versions for these packages in your catalog configuration file ` +
829+
`(e.g., pnpm-workspace.yaml or .yarnrc.yml).\n` +
830+
` 2. Run '${installCmd}' to install the updated versions.\n` +
831+
` 3. Run the following command(s) to execute the migration schematics:\n` +
832+
`${migrationCommands}`,
833+
);
834+
}
835+
}
836+
762837
export async function resolveUserUpdatePlan(
763838
options: UpdateResolverOptions,
764839
packageManager: PackageManager,
@@ -810,10 +885,19 @@ export async function resolveUserUpdatePlan(
810885
options.to = _formatVersion(options.to);
811886
const usingYarn = options.packageManager === 'yarn';
812887

813-
const packages = _buildPackageList(options, npmDeps, logger);
814888
const minReleaseAge = await packageManager.getMinimumReleaseAge();
815889
const registryClient = new RegistryClient(packageManager, logger, minReleaseAge);
816890

891+
await checkCatalogUpdates(
892+
normalizedPackages,
893+
packageJsonContent,
894+
registryClient,
895+
workspaceRoot,
896+
options,
897+
);
898+
899+
const packages = _buildPackageList(options, npmDeps, logger);
900+
817901
const getOrFetchPackageMetadata = async (
818902
packageName: string,
819903
): Promise<PackageMetadata | null> => {

packages/angular/cli/src/commands/update/update-resolver_spec.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,105 @@ describe('UpdateResolver', () => {
474474
expect(plan.packagesToUpdate.get('@angular-devkit-tests/common-bug')).toBe('13.2.0');
475475
expect(plan.packagesToUpdate.get('@angular-devkit-tests/cdk-bug')).toBe('13.2.0');
476476
});
477+
478+
it('rejects updates for catalog packages and guides on manual migration steps', async () => {
479+
createMockWorkspace(
480+
{
481+
name: 'blah',
482+
dependencies: {
483+
'@angular/core': 'catalog:',
484+
},
485+
},
486+
{
487+
'@angular/core': { version: '5.1.0' },
488+
},
489+
);
490+
491+
const expectedError = `
492+
The following packages to update are configured to use catalogs:
493+
- @angular/core \\(catalog:\\) -> Target version: 6\\.0\\.0
494+
495+
Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\.
496+
Please perform the following steps to update:
497+
1\\. Manually update the versions for these packages in your catalog.*
498+
2\\. Run 'pnpm install' to install the updated versions\\.
499+
3\\. Run the following command\\(s\\) to execute the migration schematics:
500+
ng update @angular/core --migrate-only --from 5\\.1\\.0
501+
`.trim();
502+
503+
await expectAsync(
504+
resolvePlan({
505+
packages: ['@angular/core'],
506+
workspaceRoot: tempRoot,
507+
}),
508+
).toBeRejectedWithError(new RegExp(expectedError));
509+
});
510+
511+
it('rejects updates for catalog packages specified with a version suffix', async () => {
512+
createMockWorkspace(
513+
{
514+
name: 'blah',
515+
dependencies: {
516+
'@angular/core': 'catalog:',
517+
},
518+
},
519+
{
520+
'@angular/core': { version: '5.1.0' },
521+
},
522+
);
523+
524+
const expectedError = `
525+
The following packages to update are configured to use catalogs:
526+
- @angular/core \\(catalog:\\) -> Target version: 6\\.0\\.0
527+
528+
Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\.
529+
Please perform the following steps to update:
530+
1\\. Manually update the versions for these packages in your catalog.*
531+
2\\. Run 'pnpm install' to install the updated versions\\.
532+
3\\. Run the following command\\(s\\) to execute the migration schematics:
533+
ng update @angular/core --migrate-only --from 5\\.1\\.0
534+
`.trim();
535+
536+
await expectAsync(
537+
resolvePlan({
538+
packages: ['@angular/core@6'],
539+
workspaceRoot: tempRoot,
540+
}),
541+
).toBeRejectedWithError(new RegExp(expectedError));
542+
});
543+
544+
it('rejects updates for catalog packages specified with a named catalog', async () => {
545+
createMockWorkspace(
546+
{
547+
name: 'blah',
548+
dependencies: {
549+
'@angular/core': 'catalog:framework',
550+
},
551+
},
552+
{
553+
'@angular/core': { version: '5.1.0' },
554+
},
555+
);
556+
557+
const expectedError = `
558+
The following packages to update are configured to use catalogs:
559+
- @angular/core \\(catalog:framework\\) -> Target version: 6\\.0\\.0
560+
561+
Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\.
562+
Please perform the following steps to update:
563+
1\\. Manually update the versions for these packages in your catalog.*
564+
2\\. Run 'pnpm install' to install the updated versions\\.
565+
3\\. Run the following command\\(s\\) to execute the migration schematics:
566+
ng update @angular/core --migrate-only --from 5\\.1\\.0
567+
`.trim();
568+
569+
await expectAsync(
570+
resolvePlan({
571+
packages: ['@angular/core'],
572+
workspaceRoot: tempRoot,
573+
}),
574+
).toBeRejectedWithError(new RegExp(expectedError));
575+
});
477576
});
478577

479578
describe('RegistryClient', () => {

0 commit comments

Comments
 (0)