Skip to content

Commit

Permalink
feat(@angular/cli): show optional migrations during update process
Browse files Browse the repository at this point in the history
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
  • Loading branch information
alan-agius4 authored and angular-robot[bot] committed Mar 8, 2023
1 parent f2cba37 commit 7cb5689
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 24 deletions.
153 changes: 130 additions & 23 deletions packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/

import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
import {
FileSystemCollectionDescription,
FileSystemSchematicDescription,
NodeWorkflow,
} from '@angular-devkit/schematics/tools';
import { SpawnSyncReturns, execSync, spawnSync } from 'child_process';
import { existsSync, promises as fs } from 'fs';
import { createRequire } from 'module';
Expand Down Expand Up @@ -42,6 +46,8 @@ import {
getProjectDependencies,
readPackageJson,
} from '../../utilities/package-tree';
import { askChoices } from '../../utilities/prompt';
import { isTTY } from '../../utilities/tty';
import { VERSION } from '../../utilities/version';

interface UpdateCommandArgs {
Expand All @@ -57,6 +63,16 @@ interface UpdateCommandArgs {
'create-commits': boolean;
}

interface MigrationSchematicDescription
extends SchematicDescription<FileSystemCollectionDescription, FileSystemSchematicDescription> {
version?: string;
optional?: boolean;
}

interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription {
version: string;
}

const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//;
const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json');

Expand Down Expand Up @@ -337,54 +353,89 @@ export class UpdateCommandModule extends CommandModule<UpdateCommandArgs> {
const migrationRange = new semver.Range(
'>' + (semver.prerelease(from) ? from.split('-')[0] + '-0' : from) + ' <=' + to.split('-')[0],
);
const migrations = [];

const requiredMigrations: MigrationSchematicDescriptionWithVersion[] = [];
const optionalMigrations: MigrationSchematicDescriptionWithVersion[] = [];

for (const name of collection.listSchematicNames()) {
const schematic = workflow.engine.createSchematic(name, collection);
const description = schematic.description as typeof schematic.description & {
version?: string;
};
const description = schematic.description as MigrationSchematicDescription;

description.version = coerceVersionNumber(description.version);
if (!description.version) {
continue;
}

if (semver.satisfies(description.version, migrationRange, { includePrerelease: true })) {
migrations.push(description as typeof schematic.description & { version: string });
(description.optional ? optionalMigrations : requiredMigrations).push(
description as MigrationSchematicDescriptionWithVersion,
);
}
}

if (migrations.length === 0) {
if (requiredMigrations.length === 0 && optionalMigrations.length === 0) {
return 0;
}

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

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

return this.executePackageMigrations(workflow, migrations, packageName, commit);
const result = await this.executePackageMigrations(
workflow,
requiredMigrations,
packageName,
commit,
);

if (result === 1) {
return 1;
}
}

// Optional migrations
if (optionalMigrations.length) {
this.context.logger.info(
colors.magenta(`** Optional migrations of package '${packageName}' **\n`),
);

optionalMigrations.sort(
(a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name),
);

const migrationsToRun = await this.getOptionalMigrationsToRun(
optionalMigrations,
packageName,
);

if (migrationsToRun?.length) {
return this.executePackageMigrations(workflow, migrationsToRun, packageName, commit);
}
}

return 0;
}

private async executePackageMigrations(
workflow: NodeWorkflow,
migrations: Iterable<{ name: string; description: string; collection: { name: string } }>,
migrations: MigrationSchematicDescription[],
packageName: string,
commit = false,
): Promise<number> {
): Promise<1 | 0> {
const { logger } = this.context;
for (const migration of migrations) {
const [title, ...description] = migration.description.split('. ');
const { title, description } = getMigrationTitleAndDescription(migration);

logger.info(
colors.cyan(colors.symbols.pointer) +
' ' +
colors.bold(title.endsWith('.') ? title : title + '.'),
);
logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title));

if (description.length) {
logger.info(' ' + description.join('.\n '));
if (description) {
logger.info(' ' + description);
}

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

return false;
}

private async getOptionalMigrationsToRun(
optionalMigrations: MigrationSchematicDescription[],
packageName: string,
): Promise<MigrationSchematicDescription[] | undefined> {
const { logger } = this.context;
const numberOfMigrations = optionalMigrations.length;
logger.info(
`This package has ${numberOfMigrations} optional migration${
numberOfMigrations > 1 ? 's' : ''
} that can be executed.`,
);
logger.info(''); // Extra trailing newline.

if (!isTTY()) {
for (const migration of optionalMigrations) {
const { title } = getMigrationTitleAndDescription(migration);
logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title));
logger.info(
colors.gray(` ng update ${packageName} --migration-only --name ${migration.name}`),
);
logger.info(''); // Extra trailing newline.
}

return undefined;
}

const answer = await askChoices(
`Select the migrations that you'd like to run`,
optionalMigrations.map((migration) => {
const { title } = getMigrationTitleAndDescription(migration);

return {
name: title,
value: migration.name,
};
}),
null,
);

logger.info(''); // Extra trailing newline.

return optionalMigrations.filter(({ name }) => answer?.includes(name));
}
}

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

return semver.valid(version) ?? undefined;
}

function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): {
title: string;
description: string;
} {
const [title, ...description] = migration.description.split('. ');

return {
title: title.endsWith('.') ? title : title + '.',
description: description.join('.\n '),
};
}
33 changes: 32 additions & 1 deletion packages/angular/cli/src/utilities/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/

import type { ListChoiceOptions, ListQuestion, Question } from 'inquirer';
import type {
CheckboxChoiceOptions,
CheckboxQuestion,
ListChoiceOptions,
ListQuestion,
Question,
} from 'inquirer';
import { isTTY } from './tty';

export async function askConfirmation(
Expand All @@ -17,6 +23,7 @@ export async function askConfirmation(
if (!isTTY()) {
return noTTYResponse ?? defaultResponse;
}

const question: Question = {
type: 'confirm',
name: 'confirmation',
Expand All @@ -40,6 +47,7 @@ export async function askQuestion(
if (!isTTY()) {
return noTTYResponse;
}

const question: ListQuestion = {
type: 'list',
name: 'answer',
Expand All @@ -54,3 +62,26 @@ export async function askQuestion(

return answers['answer'];
}

export async function askChoices(
message: string,
choices: CheckboxChoiceOptions[],
noTTYResponse: string[] | null,
): Promise<string[] | null> {
if (!isTTY()) {
return noTTYResponse;
}

const question: CheckboxQuestion = {
type: 'checkbox',
name: 'answer',
prefix: '',
message,
choices,
};

const { prompt } = await import('inquirer');
const answers = await prompt([question]);

return answers['answer'];
}

0 comments on commit 7cb5689

Please sign in to comment.