Skip to content

Commit 886d251

Browse files
committed
fix(@angular/cli): logic which determines which temp version of the CLI is to be download during ng update
Previously, when using an older version of the Angular CLI, during `ng update`, we download the temporary `latest` version to run the update. The ensured that when running that the runner used to run the update contains the latest bug fixes and improvements. This however, can be problematic in some cases. Such as when there are API breaking changes, when running a relatively old schematic with the latest CLI can cause runtime issues, especially since those schematics were never meant to be executed on a CLI X major versions in the future. With this change, we improve the logic to determine which version of the Angular CLI should be used to run the update. Below is a summarization of this. - When using the `--next` command line argument, the `@next` version of the CLI will be used to run the update. - When updating an `@angular/` or `@nguniversal/` package, the target version will be used to run the update. Example: `ng update @angular/core@12`, the update will run on most recent patch version of `@angular/cli` of that major version `@12.2.6`. - When updating an `@angular/` or `@nguniversal/` and no target version is specified. Example: `ng update @angular/core` the update will run on most latest version of the `@angular/cli`. - When updating a third-party package, the most recent patch version of the installed `@angular/cli` will be used to run the update. Example if `13.0.0` is installed and `13.1.1` is available on NPM, the latter will be used. (cherry picked from commit 4632f1f)
1 parent f456b09 commit 886d251

File tree

4 files changed

+122
-124
lines changed

4 files changed

+122
-124
lines changed

packages/angular/cli/commands/update-impl.ts

Lines changed: 106 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { execSync } from 'child_process';
1111
import * as fs from 'fs';
1212
import * as path from 'path';
1313
import * as semver from 'semver';
14+
import { VERSION } from '../lib/cli';
1415
import { PackageManager } from '../lib/config/schema';
1516
import { Command } from '../models/command';
1617
import { Arguments } from '../models/interface';
@@ -42,10 +43,6 @@ const pickManifest = require('npm-pick-manifest') as (
4243

4344
const oldConfigFileNames = ['.angular-cli.json', 'angular-cli.json'];
4445

45-
const NG_VERSION_9_POST_MSG = colors.cyan(
46-
'\nYour project has been updated to Angular version 9!\n' +
47-
'For more info, please see: https://v9.angular.io/guide/updating-to-version-9',
48-
);
4946

5047
/**
5148
* Disable CLI version mismatch checks and forces usage of the invoked CLI
@@ -57,24 +54,23 @@ const disableVersionCheck =
5754
disableVersionCheckEnv !== '0' &&
5855
disableVersionCheckEnv.toLowerCase() !== 'false';
5956

57+
const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//;
58+
6059
export class UpdateCommand extends Command<UpdateCommandSchema> {
6160
public readonly allowMissingWorkspace = true;
6261
private workflow!: NodeWorkflow;
6362
private packageManager = PackageManager.Npm;
6463

6564
async initialize() {
6665
this.packageManager = await getPackageManager(this.context.root);
67-
this.workflow = new NodeWorkflow(
68-
this.context.root,
69-
{
70-
packageManager: this.packageManager,
71-
// __dirname -> favor @schematics/update from this package
72-
// Otherwise, use packages from the active workspace (migrations)
73-
resolvePaths: [__dirname, this.context.root],
74-
schemaValidation: true,
75-
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
76-
},
77-
);
66+
this.workflow = new NodeWorkflow(this.context.root, {
67+
packageManager: this.packageManager,
68+
// __dirname -> favor @schematics/update from this package
69+
// Otherwise, use packages from the active workspace (migrations)
70+
resolvePaths: [__dirname, this.context.root],
71+
schemaValidation: true,
72+
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
73+
});
7874
}
7975

8076
private async executeSchematic(
@@ -86,7 +82,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
8682
let logs: string[] = [];
8783
const files = new Set<string>();
8884

89-
const reporterSubscription = this.workflow.reporter.subscribe(event => {
85+
const reporterSubscription = this.workflow.reporter.subscribe((event) => {
9086
// Strip leading slash to prevent confusion.
9187
const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path;
9288

@@ -116,11 +112,11 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
116112
}
117113
});
118114

119-
const lifecycleSubscription = this.workflow.lifeCycle.subscribe(event => {
115+
const lifecycleSubscription = this.workflow.lifeCycle.subscribe((event) => {
120116
if (event.kind == 'end' || event.kind == 'post-tasks-start') {
121117
if (!error) {
122118
// Output the logging queue, no error happened.
123-
logs.forEach(log => this.logger.info(` ${log}`));
119+
logs.forEach((log) => this.logger.info(` ${log}`));
124120
logs = [];
125121
}
126122
}
@@ -143,12 +139,14 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
143139
return { success: !error, files };
144140
} catch (e) {
145141
if (e instanceof UnsuccessfulWorkflowExecution) {
146-
this.logger.error(`${colors.symbols.cross} Migration failed. See above for further details.\n`);
142+
this.logger.error(
143+
`${colors.symbols.cross} Migration failed. See above for further details.\n`,
144+
);
147145
} else {
148146
const logPath = writeErrorToLogFile(e);
149147
this.logger.fatal(
150148
`${colors.symbols.cross} Migration failed: ${e.message}\n` +
151-
` See "${logPath}" for further details.\n`,
149+
` See "${logPath}" for further details.\n`,
152150
);
153151
}
154152

@@ -166,7 +164,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
166164
commit?: boolean,
167165
): Promise<boolean> {
168166
const collection = this.workflow.engine.createCollection(collectionPath);
169-
const name = collection.listSchematicNames().find(name => name === migrationName);
167+
const name = collection.listSchematicNames().find((name) => name === migrationName);
170168
if (!name) {
171169
this.logger.error(`Cannot find migration '${migrationName}' in '${packageName}'.`);
172170

@@ -215,24 +213,23 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
215213
return true;
216214
}
217215

218-
this.logger.info(
219-
colors.cyan(`** Executing migrations of package '${packageName}' **\n`),
220-
);
216+
this.logger.info(colors.cyan(`** Executing migrations of package '${packageName}' **\n`));
221217

222218
return this.executePackageMigrations(migrations, packageName, commit);
223219
}
224220

225221
private async executePackageMigrations(
226-
migrations: Iterable<{ name: string; description: string; collection: { name: string }}>,
222+
migrations: Iterable<{ name: string; description: string; collection: { name: string } }>,
227223
packageName: string,
228224
commit = false,
229225
): Promise<boolean> {
230226
for (const migration of migrations) {
231227
const [title, ...description] = migration.description.split('. ');
232228

233229
this.logger.info(
234-
colors.cyan(colors.symbols.pointer) + ' ' +
235-
colors.bold(title.endsWith('.') ? title : title + '.'),
230+
colors.cyan(colors.symbols.pointer) +
231+
' ' +
232+
colors.bold(title.endsWith('.') ? title : title + '.'),
236233
);
237234

238235
if (description.length) {
@@ -269,19 +266,27 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
269266
async run(options: UpdateCommandSchema & Arguments) {
270267
await ensureCompatibleNpm(this.context.root);
271268

272-
// Check if the current installed CLI version is older than the latest version.
273-
if (!disableVersionCheck && await this.checkCLILatestVersion(options.verbose, options.next)) {
274-
this.logger.warn(
275-
`The installed local Angular CLI version is older than the latest ${options.next ? 'pre-release' : 'stable'} version.\n` +
276-
'Installing a temporary version to perform the update.',
269+
// Check if the current installed CLI version is older than the latest compatible version.
270+
if (!disableVersionCheck) {
271+
const cliVersionToInstall = await this.checkCLIVersion(
272+
options['--'],
273+
options.verbose,
274+
options.next,
277275
);
278276

279-
return runTempPackageBin(
280-
`@angular/cli@${options.next ? 'next' : 'latest'}`,
281-
this.logger,
282-
this.packageManager,
283-
process.argv.slice(2),
284-
);
277+
if (cliVersionToInstall) {
278+
this.logger.warn(
279+
'The installed Angular CLI version is outdated.\n' +
280+
`Installing a temporary Angular CLI versioned ${cliVersionToInstall} to perform the update.`,
281+
);
282+
283+
return runTempPackageBin(
284+
`@angular/cli@${cliVersionToInstall}`,
285+
this.logger,
286+
this.packageManager,
287+
process.argv.slice(2),
288+
);
289+
}
285290
}
286291

287292
const logVerbose = (message: string) => {
@@ -291,9 +296,10 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
291296
};
292297

293298
if (options.all) {
294-
const updateCmd = this.packageManager === PackageManager.Yarn
295-
? `'yarn upgrade-interactive' or 'yarn upgrade'`
296-
: `'${this.packageManager} update'`;
299+
const updateCmd =
300+
this.packageManager === PackageManager.Yarn
301+
? `'yarn upgrade-interactive' or 'yarn upgrade'`
302+
: `'${this.packageManager} update'`;
297303

298304
this.logger.warn(`
299305
'--all' functionality has been removed as updating multiple packages at once is not recommended.
@@ -316,7 +322,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
316322
return 1;
317323
}
318324

319-
if (packages.some(v => v.name === packageIdentifier.name)) {
325+
if (packages.some((v) => v.name === packageIdentifier.name)) {
320326
this.logger.error(`Duplicate package '${packageIdentifier.name}' specified.`);
321327

322328
return 1;
@@ -397,7 +403,9 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
397403

398404
if (options.migrateOnly) {
399405
if (!options.from && typeof options.migrateOnly !== 'string') {
400-
this.logger.error('"from" option is required when using the "migrate-only" option without a migration name.');
406+
this.logger.error(
407+
'"from" option is required when using the "migrate-only" option without a migration name.',
408+
);
401409

402410
return 1;
403411
} else if (packages.length !== 1) {
@@ -460,8 +468,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
460468

461469
if (migrations.startsWith('../')) {
462470
this.logger.error(
463-
'Package contains an invalid migrations field. ' +
464-
'Paths outside the package root are not permitted.',
471+
'Package contains an invalid migrations field. Paths outside the package root are not permitted.',
465472
);
466473

467474
return 1;
@@ -487,9 +494,8 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
487494
}
488495
}
489496

490-
let success = false;
491497
if (typeof options.migrateOnly == 'string') {
492-
success = await this.executeMigration(
498+
await this.executeMigration(
493499
packageName,
494500
migrations,
495501
options.migrateOnly,
@@ -506,28 +512,14 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
506512
const migrationRange = new semver.Range(
507513
'>' + from + ' <=' + (options.to || packageNode.version),
508514
);
509-
510-
success = await this.executeMigrations(
515+
await this.executeMigrations(
511516
packageName,
512517
migrations,
513518
migrationRange,
514519
options.createCommits,
515520
);
516521
}
517522

518-
if (success) {
519-
if (
520-
packageName === '@angular/core'
521-
&& options.from
522-
&& +options.from.split('.')[0] < 9
523-
&& (options.to || packageNode.version).split('.')[0] === '9'
524-
) {
525-
this.logger.info(NG_VERSION_9_POST_MSG);
526-
}
527-
528-
return 0;
529-
}
530-
531523
return 1;
532524
}
533525

@@ -623,7 +615,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
623615
continue;
624616
}
625617

626-
if (node.package && /^@(?:angular|nguniversal)\//.test(node.package.name)) {
618+
if (node.package && ANGULAR_PACKAGES_REGEXP.test(node.package.name)) {
627619
const { name, version } = node.package;
628620
const toBeInstalledMajorVersion = +manifest.version.split('.')[0];
629621
const currentMajorVersion = +version.split('.')[0];
@@ -670,7 +662,8 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
670662

671663
if (success && options.createCommits) {
672664
const committed = this.commit(
673-
`Angular CLI update for packages - ${packagesToUpdate.join(', ')}`);
665+
`Angular CLI update for packages - ${packagesToUpdate.join(', ')}`,
666+
);
674667
if (!committed) {
675668
return 1;
676669
}
@@ -759,10 +752,6 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
759752
return 0;
760753
}
761754
}
762-
763-
if (migrations.some(m => m.package === '@angular/core' && m.to.split('.')[0] === '9' && +m.from.split('.')[0] < 9)) {
764-
this.logger.info(NG_VERSION_9_POST_MSG);
765-
}
766755
}
767756

768757
return success ? 0 : 1;
@@ -792,8 +781,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
792781
try {
793782
createCommit(message);
794783
} catch (err) {
795-
this.logger.error(
796-
`Failed to commit update (${message}):\n${err.stderr}`);
784+
this.logger.error(`Failed to commit update (${message}):\n${err.stderr}`);
797785

798786
return false;
799787
}
@@ -802,8 +790,7 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
802790
const hash = findCurrentGitSha();
803791
const shortMessage = message.split('\n')[0];
804792
if (hash) {
805-
this.logger.info(` Committed migration step (${getShortHash(hash)}): ${
806-
shortMessage}.`);
793+
this.logger.info(` Committed migration step (${getShortHash(hash)}): ${shortMessage}.`);
807794
} else {
808795
// Commit was successful, but reading the hash was not. Something weird happened,
809796
// but nothing that would stop the update. Just log the weirdness and continue.
@@ -816,7 +803,10 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
816803

817804
private checkCleanGit(): boolean {
818805
try {
819-
const topLevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: 'pipe' });
806+
const topLevel = execSync('git rev-parse --show-toplevel', {
807+
encoding: 'utf8',
808+
stdio: 'pipe',
809+
});
820810
const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' });
821811
if (result.trim().length === 0) {
822812
return true;
@@ -839,22 +829,55 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
839829
}
840830

841831
/**
842-
* Checks if the current installed CLI version is older than the latest version.
843-
* @returns `true` when the installed version is older.
844-
*/
845-
private async checkCLILatestVersion(verbose = false, next = false): Promise<boolean> {
846-
const { version: installedCLIVersion } = require('../package.json');
847-
848-
const LatestCLIManifest = await fetchPackageManifest(
849-
`@angular/cli@${next ? 'next' : 'latest'}`,
832+
* Checks if the current installed CLI version is older or newer than a compatible version.
833+
* @returns the version to install or null when there is no update to install.
834+
*/
835+
private async checkCLIVersion(
836+
packagesToUpdate: string[] | undefined,
837+
verbose = false,
838+
next = false,
839+
): Promise<string | null> {
840+
const { version } = await fetchPackageManifest(
841+
`@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`,
850842
this.logger,
851843
{
852844
verbose,
853845
usingYarn: this.packageManager === PackageManager.Yarn,
854846
},
855847
);
856848

857-
return semver.lt(installedCLIVersion, LatestCLIManifest.version);
849+
return VERSION.full === version ? null : version;
850+
}
851+
852+
private getCLIUpdateRunnerVersion(
853+
packagesToUpdate: string[] | undefined,
854+
next: boolean,
855+
): string | number {
856+
if (next) {
857+
return 'next';
858+
}
859+
860+
const updatingAngularPackage = packagesToUpdate?.find((r) => ANGULAR_PACKAGES_REGEXP.test(r));
861+
if (updatingAngularPackage) {
862+
// If we are updating any Angular package we can update the CLI to the target version because
863+
// migrations for @angular/core@13 can be executed using Angular/cli@13.
864+
// This is same behaviour as `npx @angular/cli@13 update @angular/core@13`.
865+
866+
// `@angular/cli@13` -> ['', 'angular/cli', '13']
867+
// `@angular/cli` -> ['', 'angular/cli']
868+
const tempVersion = coerceVersionNumber(updatingAngularPackage.split('@')[2]);
869+
870+
return semver.parse(tempVersion)?.major ?? 'latest';
871+
}
872+
873+
// When not updating an Angular package we cannot determine which schematic runtime the migration should to be executed in.
874+
// Typically, we can assume that the `@angular/cli` was updated previously.
875+
// Example: Angular official packages are typically updated prior to NGRX etc...
876+
// Therefore, we only update to the latest patch version of the installed major version of the Angular CLI.
877+
878+
// This is important because we might end up in a scenario where locally Angular v12 is installed, updating NGRX from 11 to 12.
879+
// We end up using Angular ClI v13 to run the migrations if we run the migrations using the CLI installed major version + 1 logic.
880+
return VERSION.major;
858881
}
859882
}
860883

@@ -887,7 +910,7 @@ function createCommit(message: string) {
887910
*/
888911
function findCurrentGitSha(): string | null {
889912
try {
890-
const hash = execSync('git rev-parse HEAD', {encoding: 'utf8', stdio: 'pipe'});
913+
const hash = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' });
891914

892915
return hash.trim();
893916
} catch {

0 commit comments

Comments
 (0)