Skip to content

Commit 7cb5689

Browse files
alan-agius4angular-robot[bot]
authored andcommitted
feat(@angular/cli): show optional migrations during update process
When running `ng update` we now display optional migrations from packages. When the terminal is interactive, we prompt the users and ask them to choose which migrations they would like to run. ``` $ ng update @angular/core --from=14 --migrate-only --allow-dirty Using package manager: yarn Collecting installed dependencies... Found 22 dependencies. ** Executing migrations of package '@angular/core' ** ▸ Since Angular v15, the `RouterLink` contains the logic of the `RouterLinkWithHref` directive. This migration replaces all `RouterLinkWithHref` references with `RouterLink`. Migration completed (No changes made). ** Optional migrations of package '@angular/core' ** This package have 2 optional migrations that can be executed. Select the migrations that you'd like to run (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed) ❯◯ Update server builds to use generate ESM output. ◯ Lorem ipsum dolor sit amet, consectetur adipiscing elit. ``` In case the terminal is non interactive, we will print the commands that need to be executed to run the optional migrations. ``` $ ng update @angular/core --from=14 --migrate-only --allow-dirty Using package manager: yarn Collecting installed dependencies... Found 22 dependencies. ** Executing migrations of package '@angular/core' ** ▸ Since Angular v15, the `RouterLink` contains the logic of the `RouterLinkWithHref` directive. This migration replaces all `RouterLinkWithHref` references with `RouterLink`. Migration completed (No changes made). ** Optional migrations of package '@angular/core' ** This package have 2 optional migrations that can be executed. ▸ Update server builds to use generate ESM output. ng update @angular/core --migration-only --name esm-server-builds ▸ Lorem ipsum dolor sit amet, consectetur adipiscing elit. ng update @angular/core --migration-only --name migration-v15-router-link-with-href ``` **Note:** Optional migrations are defined by setting the `optional` property to `true`. Example: ```json { "schematics": { "esm-server-builds": { "version": "15.0.0", "description": "Update server builds to use generate ESM output", "factory": "./migrations/relative-link-resolution/bundle", "optional": true } } ``` Closes #23205
1 parent f2cba37 commit 7cb5689

File tree

2 files changed

+162
-24
lines changed

2 files changed

+162
-24
lines changed

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

+130-23
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
10-
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
9+
import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
10+
import {
11+
FileSystemCollectionDescription,
12+
FileSystemSchematicDescription,
13+
NodeWorkflow,
14+
} from '@angular-devkit/schematics/tools';
1115
import { SpawnSyncReturns, execSync, spawnSync } from 'child_process';
1216
import { existsSync, promises as fs } from 'fs';
1317
import { createRequire } from 'module';
@@ -42,6 +46,8 @@ import {
4246
getProjectDependencies,
4347
readPackageJson,
4448
} from '../../utilities/package-tree';
49+
import { askChoices } from '../../utilities/prompt';
50+
import { isTTY } from '../../utilities/tty';
4551
import { VERSION } from '../../utilities/version';
4652

4753
interface UpdateCommandArgs {
@@ -57,6 +63,16 @@ interface UpdateCommandArgs {
5763
'create-commits': boolean;
5864
}
5965

66+
interface MigrationSchematicDescription
67+
extends SchematicDescription<FileSystemCollectionDescription, FileSystemSchematicDescription> {
68+
version?: string;
69+
optional?: boolean;
70+
}
71+
72+
interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription {
73+
version: string;
74+
}
75+
6076
const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//;
6177
const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json');
6278

@@ -337,54 +353,89 @@ export class UpdateCommandModule extends CommandModule<UpdateCommandArgs> {
337353
const migrationRange = new semver.Range(
338354
'>' + (semver.prerelease(from) ? from.split('-')[0] + '-0' : from) + ' <=' + to.split('-')[0],
339355
);
340-
const migrations = [];
356+
357+
const requiredMigrations: MigrationSchematicDescriptionWithVersion[] = [];
358+
const optionalMigrations: MigrationSchematicDescriptionWithVersion[] = [];
341359

342360
for (const name of collection.listSchematicNames()) {
343361
const schematic = workflow.engine.createSchematic(name, collection);
344-
const description = schematic.description as typeof schematic.description & {
345-
version?: string;
346-
};
362+
const description = schematic.description as MigrationSchematicDescription;
363+
347364
description.version = coerceVersionNumber(description.version);
348365
if (!description.version) {
349366
continue;
350367
}
351368

352369
if (semver.satisfies(description.version, migrationRange, { includePrerelease: true })) {
353-
migrations.push(description as typeof schematic.description & { version: string });
370+
(description.optional ? optionalMigrations : requiredMigrations).push(
371+
description as MigrationSchematicDescriptionWithVersion,
372+
);
354373
}
355374
}
356375

357-
if (migrations.length === 0) {
376+
if (requiredMigrations.length === 0 && optionalMigrations.length === 0) {
358377
return 0;
359378
}
360379

361-
migrations.sort((a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name));
380+
// Required migrations
381+
if (requiredMigrations.length) {
382+
this.context.logger.info(
383+
colors.cyan(`** Executing migrations of package '${packageName}' **\n`),
384+
);
362385

363-
this.context.logger.info(
364-
colors.cyan(`** Executing migrations of package '${packageName}' **\n`),
365-
);
386+
requiredMigrations.sort(
387+
(a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name),
388+
);
366389

367-
return this.executePackageMigrations(workflow, migrations, packageName, commit);
390+
const result = await this.executePackageMigrations(
391+
workflow,
392+
requiredMigrations,
393+
packageName,
394+
commit,
395+
);
396+
397+
if (result === 1) {
398+
return 1;
399+
}
400+
}
401+
402+
// Optional migrations
403+
if (optionalMigrations.length) {
404+
this.context.logger.info(
405+
colors.magenta(`** Optional migrations of package '${packageName}' **\n`),
406+
);
407+
408+
optionalMigrations.sort(
409+
(a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name),
410+
);
411+
412+
const migrationsToRun = await this.getOptionalMigrationsToRun(
413+
optionalMigrations,
414+
packageName,
415+
);
416+
417+
if (migrationsToRun?.length) {
418+
return this.executePackageMigrations(workflow, migrationsToRun, packageName, commit);
419+
}
420+
}
421+
422+
return 0;
368423
}
369424

370425
private async executePackageMigrations(
371426
workflow: NodeWorkflow,
372-
migrations: Iterable<{ name: string; description: string; collection: { name: string } }>,
427+
migrations: MigrationSchematicDescription[],
373428
packageName: string,
374429
commit = false,
375-
): Promise<number> {
430+
): Promise<1 | 0> {
376431
const { logger } = this.context;
377432
for (const migration of migrations) {
378-
const [title, ...description] = migration.description.split('. ');
433+
const { title, description } = getMigrationTitleAndDescription(migration);
379434

380-
logger.info(
381-
colors.cyan(colors.symbols.pointer) +
382-
' ' +
383-
colors.bold(title.endsWith('.') ? title : title + '.'),
384-
);
435+
logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title));
385436

386-
if (description.length) {
387-
logger.info(' ' + description.join('.\n '));
437+
if (description) {
438+
logger.info(' ' + description);
388439
}
389440

390441
const { success, files } = await this.executeSchematic(
@@ -1015,6 +1066,50 @@ export class UpdateCommandModule extends CommandModule<UpdateCommandArgs> {
10151066

10161067
return false;
10171068
}
1069+
1070+
private async getOptionalMigrationsToRun(
1071+
optionalMigrations: MigrationSchematicDescription[],
1072+
packageName: string,
1073+
): Promise<MigrationSchematicDescription[] | undefined> {
1074+
const { logger } = this.context;
1075+
const numberOfMigrations = optionalMigrations.length;
1076+
logger.info(
1077+
`This package has ${numberOfMigrations} optional migration${
1078+
numberOfMigrations > 1 ? 's' : ''
1079+
} that can be executed.`,
1080+
);
1081+
logger.info(''); // Extra trailing newline.
1082+
1083+
if (!isTTY()) {
1084+
for (const migration of optionalMigrations) {
1085+
const { title } = getMigrationTitleAndDescription(migration);
1086+
logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title));
1087+
logger.info(
1088+
colors.gray(` ng update ${packageName} --migration-only --name ${migration.name}`),
1089+
);
1090+
logger.info(''); // Extra trailing newline.
1091+
}
1092+
1093+
return undefined;
1094+
}
1095+
1096+
const answer = await askChoices(
1097+
`Select the migrations that you'd like to run`,
1098+
optionalMigrations.map((migration) => {
1099+
const { title } = getMigrationTitleAndDescription(migration);
1100+
1101+
return {
1102+
name: title,
1103+
value: migration.name,
1104+
};
1105+
}),
1106+
null,
1107+
);
1108+
1109+
logger.info(''); // Extra trailing newline.
1110+
1111+
return optionalMigrations.filter(({ name }) => answer?.includes(name));
1112+
}
10181113
}
10191114

10201115
/**
@@ -1078,3 +1173,15 @@ function coerceVersionNumber(version: string | undefined): string | undefined {
10781173

10791174
return semver.valid(version) ?? undefined;
10801175
}
1176+
1177+
function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): {
1178+
title: string;
1179+
description: string;
1180+
} {
1181+
const [title, ...description] = migration.description.split('. ');
1182+
1183+
return {
1184+
title: title.endsWith('.') ? title : title + '.',
1185+
description: description.join('.\n '),
1186+
};
1187+
}

packages/angular/cli/src/utilities/prompt.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import type { ListChoiceOptions, ListQuestion, Question } from 'inquirer';
9+
import type {
10+
CheckboxChoiceOptions,
11+
CheckboxQuestion,
12+
ListChoiceOptions,
13+
ListQuestion,
14+
Question,
15+
} from 'inquirer';
1016
import { isTTY } from './tty';
1117

1218
export async function askConfirmation(
@@ -17,6 +23,7 @@ export async function askConfirmation(
1723
if (!isTTY()) {
1824
return noTTYResponse ?? defaultResponse;
1925
}
26+
2027
const question: Question = {
2128
type: 'confirm',
2229
name: 'confirmation',
@@ -40,6 +47,7 @@ export async function askQuestion(
4047
if (!isTTY()) {
4148
return noTTYResponse;
4249
}
50+
4351
const question: ListQuestion = {
4452
type: 'list',
4553
name: 'answer',
@@ -54,3 +62,26 @@ export async function askQuestion(
5462

5563
return answers['answer'];
5664
}
65+
66+
export async function askChoices(
67+
message: string,
68+
choices: CheckboxChoiceOptions[],
69+
noTTYResponse: string[] | null,
70+
): Promise<string[] | null> {
71+
if (!isTTY()) {
72+
return noTTYResponse;
73+
}
74+
75+
const question: CheckboxQuestion = {
76+
type: 'checkbox',
77+
name: 'answer',
78+
prefix: '',
79+
message,
80+
choices,
81+
};
82+
83+
const { prompt } = await import('inquirer');
84+
const answers = await prompt([question]);
85+
86+
return answers['answer'];
87+
}

0 commit comments

Comments
 (0)