diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 511223eb53b5..bc95f33ff8e7 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -224,29 +224,12 @@ export default class AddCommandModule { title: 'Confirming installation', enabled: !skipConfirmation && !options.dryRun, - skip: (context) => { - if (context.hasSchematics) { - return false; - } - - return `The ${color.blue(context.packageIdentifier.toString())} package does not provide \`ng add\` actions.`; - }, task: (context, task) => this.confirmInstallationTask(context, task), rendererOptions: { persistentOutput: true }, }, { title: 'Installing package', skip: (context) => { - if (!context.hasSchematics) { - const builtInSchematic = - BUILT_IN_SCHEMATICS[ - context.packageIdentifier.name as keyof typeof BUILT_IN_SCHEMATICS - ]; - if (builtInSchematic) { - return `Skipping package installation.`; - } - } - if (context.dryRun) { return `Skipping package installation. Would install package ${color.blue( context.packageIdentifier.toString(), @@ -278,6 +261,9 @@ export default class AddCommandModule if (localManifest['ng-add']?.save === false) { shouldCleanUp = true; } + } else { + await this.cleanUpTemporaryDependency(result.collectionName); + shouldCleanUp = false; } } catch {} } @@ -305,6 +291,9 @@ export default class AddCommandModule const builtInSchematic = BUILT_IN_SCHEMATICS[packageName as keyof typeof BUILT_IN_SCHEMATICS]; if (builtInSchematic) { + logger.info( + `The ${color.blue(packageName)} package does not provide \`ng add\` actions.`, + ); logger.info('The Angular CLI will use built-in actions to add it to your project.'); return this.executeSchematic({ diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index b886c287f2ec..005df998b501 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -634,7 +634,7 @@ export default class UpdateCommandModule extends CommandModule/package.json` after installation, + * we ensure that any migration collections defined by the package are discovered and queued. + */ +export async function resolveFallbackMigrations( + workspaceRoot: string, + plan: UpdatePlan, +): Promise<{ package: string; collection: string; from: string; to: string }[]> { + const migrations = [...plan.migrationsToRun]; + const existingMigrationPackages = new Set(migrations.map((m) => m.package)); + + for (const [packageName, targetVersion] of plan.packagesToUpdate) { + if (existingMigrationPackages.has(packageName)) { + continue; + } + + const packageJsonPath = findPackageJson(workspaceRoot, packageName); + if (packageJsonPath) { + try { + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + const ngUpdate = packageJson?.['ng-update']; + if (ngUpdate && typeof ngUpdate === 'object' && typeof ngUpdate.migrations === 'string') { + const installedVersion = plan.packageInfoMap.get(packageName)?.installed.version; + if (installedVersion) { + migrations.push({ + package: packageName, + collection: ngUpdate.migrations, + from: installedVersion, + to: targetVersion, + }); + } + } + } catch { + // Ignore read/parse errors for optional fallback + } + } + } + + return migrations; +} diff --git a/packages/angular/cli/src/commands/update/cli_spec.ts b/packages/angular/cli/src/commands/update/cli_spec.ts new file mode 100644 index 000000000000..bae00d3dba24 --- /dev/null +++ b/packages/angular/cli/src/commands/update/cli_spec.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'path'; +import { resolveFallbackMigrations } from './cli'; +import type { PackageVersionInfo, UpdatePlan } from './update-resolver'; + +describe('resolveFallbackMigrations', () => { + let tempRoot: string; + let pkgDir: string; + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(path.join(baseTmpDir, 'angular-cli-update-cli-test-')); + pkgDir = path.join(tempRoot, 'node_modules/@company/library-name'); + await mkdir(pkgDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + it('discovers migrations from installed package.json when omitted from plan.migrationsToRun', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + 'ng-update': { + migrations: './schematics/migration.json', + }, + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [], + packageInfoMap: new Map([ + [ + '@company/library-name', + { + name: '@company/library-name', + npmPackageJson: { + name: '@company/library-name', + versions: ['21.1.0', '21.2.0-next.1'], + 'dist-tags': {}, + }, + installed: { + version: '21.1.0' as unknown as PackageVersionInfo['version'], + packageJson: { name: '@company/library-name', version: '21.1.0' }, + updateMetadata: { packageGroup: {}, requirements: {} }, + }, + packageJsonRange: '^21.1.0', + }, + ], + ]), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toEqual([ + { + package: '@company/library-name', + collection: './schematics/migration.json', + from: '21.1.0', + to: '21.2.0-next.1', + }, + ]); + }); + + it('does not duplicate migration if package is already in plan.migrationsToRun', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + 'ng-update': { + migrations: './schematics/migration.json', + }, + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [ + { + package: '@company/library-name', + collection: './schematics/migration.json', + from: '21.1.0', + to: '21.2.0-next.1', + }, + ], + packageInfoMap: new Map(), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toHaveSize(1); + }); + + it('returns unchanged migrations when package has no ng-update field on disk', async () => { + await writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@company/library-name', + version: '21.2.0-next.1', + }), + 'utf8', + ); + + const plan: UpdatePlan = { + packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]), + migrationsToRun: [], + packageInfoMap: new Map(), + registryClient: undefined as unknown as UpdatePlan['registryClient'], + }; + + const migrations = await resolveFallbackMigrations(tempRoot, plan); + + expect(migrations).toHaveSize(0); + }); +});