Skip to content

feat(@angular/cli): show optional migrations during update process #24825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'];
}