From 790e09078a607a6d89a1d1cdd82fb16923c78065 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:09:52 -0400 Subject: [PATCH] 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 '. --- .../src/commands/update/update-resolver.ts | 116 +++++++++++++++--- .../commands/update/update-resolver_spec.ts | 99 +++++++++++++++ 2 files changed, 199 insertions(+), 16 deletions(-) diff --git a/packages/angular/cli/src/commands/update/update-resolver.ts b/packages/angular/cli/src/commands/update/update-resolver.ts index 90cd04043201..ad66aef3e942 100644 --- a/packages/angular/cli/src/commands/update/update-resolver.ts +++ b/packages/angular/cli/src/commands/update/update-resolver.ts @@ -541,6 +541,23 @@ async function _buildPackageInfo( }; } +function splitPackageName(pkg: string): { name: string; version?: string } { + let name = pkg; + let version: string | undefined; + + if (pkg.startsWith('@')) { + const parts = pkg.split('@'); + name = '@' + parts[1]; + version = parts[2]; + } else if (pkg.includes('@')) { + const parts = pkg.split('@'); + name = parts[0]; + version = parts[1]; + } + + return { name, version }; +} + function _buildPackageList( options: UpdateResolverOptions, allDependencies: ReadonlyMap, @@ -554,28 +571,18 @@ function _buildPackageList( } for (const pkg of inputPackages) { - let pkgName = pkg; - let pkgVersion: string | undefined; - - if (pkg.startsWith('@')) { - const parts = pkg.split('@'); - pkgName = '@' + parts[1]; - pkgVersion = parts[2]; - } else if (pkg.includes('@')) { - const parts = pkg.split('@'); - pkgName = parts[0]; - pkgVersion = parts[1]; - } + const { name: pkgName, version: pkgVersion } = splitPackageName(pkg); if (!allDependencies.has(pkgName)) { throw new Error(`Package ${JSON.stringify(pkgName)} is not in package.json.`); } - if (options.migrateOnly && !pkgVersion && options.from) { - pkgVersion = options.from; + let targetVersion = pkgVersion; + if (options.migrateOnly && !targetVersion && options.from) { + targetVersion = options.from; } - packages.set(pkgName, (pkgVersion || (options.next ? 'next' : 'latest')) as VersionRange); + packages.set(pkgName, (targetVersion || (options.next ? 'next' : 'latest')) as VersionRange); } return packages; @@ -759,6 +766,74 @@ function isPkgFromRegistry(name: string, specifier: string): boolean { return !!result.registry; } +async function checkCatalogUpdates( + normalizedPackages: string[], + packageJsonContent: PackageManifest, + registryClient: RegistryClient, + workspaceRoot: string, + options: UpdateResolverOptions, +): Promise { + const catalogUpdates: { name: string; current: string; target: string; specifier: string }[] = []; + + for (const requestedPkg of normalizedPackages) { + const { name: pkgName } = splitPackageName(requestedPkg); + const specifier = + packageJsonContent.dependencies?.[pkgName] || + packageJsonContent.devDependencies?.[pkgName] || + packageJsonContent.peerDependencies?.[pkgName]; + + if (specifier?.startsWith('catalog:')) { + const current = getInstalledVersion(pkgName, workspaceRoot) ?? 'unknown'; + let target = 'latest'; + try { + const metadata = await registryClient.getMetadata(pkgName); + if (metadata) { + const resolved = await resolvePackageVersion( + registryClient, + metadata, + options.next ? 'next' : 'latest', + !!options.next, + ); + target = resolved ?? 'latest'; + } + } catch { + // Fallback to 'latest' tag + } + + catalogUpdates.push({ name: pkgName, current, target, specifier }); + } + } + + if (catalogUpdates.length > 0) { + const packageManagerName = options.packageManager ?? 'your package manager'; + const installCmd = packageManagerName === 'yarn' ? 'yarn install' : 'pnpm install'; + + const updatesList = catalogUpdates + .map((pkg) => ` - ${pkg.name} (${pkg.specifier}) -> Target version: ${pkg.target}`) + .join('\n'); + + const migrationCommands = catalogUpdates + .map((pkg) => { + const fromVer = pkg.current === 'unknown' ? '' : pkg.current; + + return ` ng update ${pkg.name} --migrate-only --from ${fromVer}`; + }) + .join('\n'); + + throw new Error( + `The following packages to update are configured to use \`catalog:\`:\n` + + `${updatesList}\n\n` + + `Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly.\n` + + `Please perform the following steps to update:\n` + + ` 1. Manually update the versions for these packages in your catalog configuration file ` + + `(e.g., pnpm-workspace.yaml or .yarnrc.yml).\n` + + ` 2. Run '${installCmd}' to install the updated versions.\n` + + ` 3. Run the following command(s) from the workspace root to execute the migration schematics:\n` + + `${migrationCommands}`, + ); + } +} + export async function resolveUserUpdatePlan( options: UpdateResolverOptions, packageManager: PackageManager, @@ -810,10 +885,19 @@ export async function resolveUserUpdatePlan( options.to = _formatVersion(options.to); const usingYarn = options.packageManager === 'yarn'; - const packages = _buildPackageList(options, npmDeps, logger); const minReleaseAge = await packageManager.getMinimumReleaseAge(); const registryClient = new RegistryClient(packageManager, logger, minReleaseAge); + await checkCatalogUpdates( + normalizedPackages, + packageJsonContent, + registryClient, + workspaceRoot, + options, + ); + + const packages = _buildPackageList(options, npmDeps, logger); + const getOrFetchPackageMetadata = async ( packageName: string, ): Promise => { diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts index 9cf4a4d826e1..6303d045dbc3 100644 --- a/packages/angular/cli/src/commands/update/update-resolver_spec.ts +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -474,6 +474,105 @@ describe('UpdateResolver', () => { expect(plan.packagesToUpdate.get('@angular-devkit-tests/common-bug')).toBe('13.2.0'); expect(plan.packagesToUpdate.get('@angular-devkit-tests/cdk-bug')).toBe('13.2.0'); }); + + it('rejects updates for catalog packages and guides on manual migration steps', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular/core': 'catalog:', + }, + }, + { + '@angular/core': { version: '5.1.0' }, + }, + ); + + const expectedError = ` +The following packages to update are configured to use \`catalog:\`: + - @angular/core \\(catalog:\\) -> Target version: 6\\.0\\.0 + +Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\. +Please perform the following steps to update: + 1\\. Manually update the versions for these packages in your catalog.* + 2\\. Run 'pnpm install' to install the updated versions\\. + 3\\. Run the following command\\(s\\) from the workspace root to execute the migration schematics: + ng update @angular/core --migrate-only --from 5\\.1\\.0 + `.trim(); + + await expectAsync( + resolvePlan({ + packages: ['@angular/core'], + workspaceRoot: tempRoot, + }), + ).toBeRejectedWithError(new RegExp(expectedError)); + }); + + it('rejects updates for catalog packages specified with a version suffix', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular/core': 'catalog:', + }, + }, + { + '@angular/core': { version: '5.1.0' }, + }, + ); + + const expectedError = ` +The following packages to update are configured to use \`catalog:\`: + - @angular/core \\(catalog:\\) -> Target version: 6\\.0\\.0 + +Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\. +Please perform the following steps to update: + 1\\. Manually update the versions for these packages in your catalog.* + 2\\. Run 'pnpm install' to install the updated versions\\. + 3\\. Run the following command\\(s\\) from the workspace root to execute the migration schematics: + ng update @angular/core --migrate-only --from 5\\.1\\.0 + `.trim(); + + await expectAsync( + resolvePlan({ + packages: ['@angular/core@6'], + workspaceRoot: tempRoot, + }), + ).toBeRejectedWithError(new RegExp(expectedError)); + }); + + it('rejects updates for catalog packages specified with a named catalog', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular/core': 'catalog:framework', + }, + }, + { + '@angular/core': { version: '5.1.0' }, + }, + ); + + const expectedError = ` +The following packages to update are configured to use \`catalog:\`: + - @angular/core \\(catalog:framework\\) -> Target version: 6\\.0\\.0 + +Because catalogs are shared across the monorepo, 'ng update' cannot modify them directly\\. +Please perform the following steps to update: + 1\\. Manually update the versions for these packages in your catalog.* + 2\\. Run 'pnpm install' to install the updated versions\\. + 3\\. Run the following command\\(s\\) from the workspace root to execute the migration schematics: + ng update @angular/core --migrate-only --from 5\\.1\\.0 + `.trim(); + + await expectAsync( + resolvePlan({ + packages: ['@angular/core'], + workspaceRoot: tempRoot, + }), + ).toBeRejectedWithError(new RegExp(expectedError)); + }); }); describe('RegistryClient', () => {