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', () => {