diff --git a/packages/angular/cli/commands/update-impl.ts b/packages/angular/cli/commands/update-impl.ts index af9a21e43c43..485fe6e05e65 100644 --- a/packages/angular/cli/commands/update-impl.ts +++ b/packages/angular/cli/commands/update-impl.ts @@ -11,6 +11,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as semver from 'semver'; +import { VERSION } from '../lib/cli'; import { PackageManager } from '../lib/config/schema'; import { Command } from '../models/command'; import { Arguments } from '../models/interface'; @@ -42,11 +43,6 @@ const pickManifest = require('npm-pick-manifest') as ( const oldConfigFileNames = ['.angular-cli.json', 'angular-cli.json']; -const NG_VERSION_9_POST_MSG = colors.cyan( - '\nYour project has been updated to Angular version 9!\n' + - 'For more info, please see: https://v9.angular.io/guide/updating-to-version-9', -); - /** * Disable CLI version mismatch checks and forces usage of the invoked CLI * instead of invoking the local installed version. @@ -57,6 +53,8 @@ const disableVersionCheck = disableVersionCheckEnv !== '0' && disableVersionCheckEnv.toLowerCase() !== 'false'; +const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; + export class UpdateCommand extends Command { public readonly allowMissingWorkspace = true; private workflow!: NodeWorkflow; @@ -64,17 +62,14 @@ export class UpdateCommand extends Command { async initialize() { this.packageManager = await getPackageManager(this.context.root); - this.workflow = new NodeWorkflow( - this.context.root, - { - packageManager: this.packageManager, - // __dirname -> favor @schematics/update from this package - // Otherwise, use packages from the active workspace (migrations) - resolvePaths: [__dirname, this.context.root], - schemaValidation: true, - engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), - }, - ); + this.workflow = new NodeWorkflow(this.context.root, { + packageManager: this.packageManager, + // __dirname -> favor @schematics/update from this package + // Otherwise, use packages from the active workspace (migrations) + resolvePaths: [__dirname, this.context.root], + schemaValidation: true, + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); } private async executeSchematic( @@ -86,7 +81,7 @@ export class UpdateCommand extends Command { let logs: string[] = []; const files = new Set(); - const reporterSubscription = this.workflow.reporter.subscribe(event => { + const reporterSubscription = this.workflow.reporter.subscribe((event) => { // Strip leading slash to prevent confusion. const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; @@ -116,11 +111,11 @@ export class UpdateCommand extends Command { } }); - const lifecycleSubscription = this.workflow.lifeCycle.subscribe(event => { + const lifecycleSubscription = this.workflow.lifeCycle.subscribe((event) => { if (event.kind == 'end' || event.kind == 'post-tasks-start') { if (!error) { // Output the logging queue, no error happened. - logs.forEach(log => this.logger.info(` ${log}`)); + logs.forEach((log) => this.logger.info(` ${log}`)); logs = []; } } @@ -143,12 +138,14 @@ export class UpdateCommand extends Command { return { success: !error, files }; } catch (e) { if (e instanceof UnsuccessfulWorkflowExecution) { - this.logger.error(`${colors.symbols.cross} Migration failed. See above for further details.\n`); + this.logger.error( + `${colors.symbols.cross} Migration failed. See above for further details.\n`, + ); } else { const logPath = writeErrorToLogFile(e); this.logger.fatal( `${colors.symbols.cross} Migration failed: ${e.message}\n` + - ` See "${logPath}" for further details.\n`, + ` See "${logPath}" for further details.\n`, ); } @@ -166,7 +163,7 @@ export class UpdateCommand extends Command { commit?: boolean, ): Promise { const collection = this.workflow.engine.createCollection(collectionPath); - const name = collection.listSchematicNames().find(name => name === migrationName); + const name = collection.listSchematicNames().find((name) => name === migrationName); if (!name) { this.logger.error(`Cannot find migration '${migrationName}' in '${packageName}'.`); @@ -215,15 +212,13 @@ export class UpdateCommand extends Command { return true; } - this.logger.info( - colors.cyan(`** Executing migrations of package '${packageName}' **\n`), - ); + this.logger.info(colors.cyan(`** Executing migrations of package '${packageName}' **\n`)); return this.executePackageMigrations(migrations, packageName, commit); } private async executePackageMigrations( - migrations: Iterable<{ name: string; description: string; collection: { name: string }}>, + migrations: Iterable<{ name: string; description: string; collection: { name: string } }>, packageName: string, commit = false, ): Promise { @@ -231,8 +226,9 @@ export class UpdateCommand extends Command { const [title, ...description] = migration.description.split('. '); this.logger.info( - colors.cyan(colors.symbols.pointer) + ' ' + - colors.bold(title.endsWith('.') ? title : title + '.'), + colors.cyan(colors.symbols.pointer) + + ' ' + + colors.bold(title.endsWith('.') ? title : title + '.'), ); if (description.length) { @@ -269,19 +265,27 @@ export class UpdateCommand extends Command { async run(options: UpdateCommandSchema & Arguments) { await ensureCompatibleNpm(this.context.root); - // Check if the current installed CLI version is older than the latest version. - if (!disableVersionCheck && await this.checkCLILatestVersion(options.verbose, options.next)) { - this.logger.warn( - `The installed local Angular CLI version is older than the latest ${options.next ? 'pre-release' : 'stable'} version.\n` + - 'Installing a temporary version to perform the update.', + // Check if the current installed CLI version is older than the latest compatible version. + if (!disableVersionCheck) { + const cliVersionToInstall = await this.checkCLIVersion( + options['--'], + options.verbose, + options.next, ); - return runTempPackageBin( - `@angular/cli@${options.next ? 'next' : 'latest'}`, - this.logger, - this.packageManager, - process.argv.slice(2), - ); + if (cliVersionToInstall) { + this.logger.warn( + 'The installed Angular CLI version is outdated.\n' + + `Installing a temporary Angular CLI versioned ${cliVersionToInstall} to perform the update.`, + ); + + return runTempPackageBin( + `@angular/cli@${cliVersionToInstall}`, + this.logger, + this.packageManager, + process.argv.slice(2), + ); + } } const logVerbose = (message: string) => { @@ -291,9 +295,10 @@ export class UpdateCommand extends Command { }; if (options.all) { - const updateCmd = this.packageManager === PackageManager.Yarn - ? `'yarn upgrade-interactive' or 'yarn upgrade'` - : `'${this.packageManager} update'`; + const updateCmd = + this.packageManager === PackageManager.Yarn + ? `'yarn upgrade-interactive' or 'yarn upgrade'` + : `'${this.packageManager} update'`; this.logger.warn(` '--all' functionality has been removed as updating multiple packages at once is not recommended. @@ -316,7 +321,7 @@ export class UpdateCommand extends Command { return 1; } - if (packages.some(v => v.name === packageIdentifier.name)) { + if (packages.some((v) => v.name === packageIdentifier.name)) { this.logger.error(`Duplicate package '${packageIdentifier.name}' specified.`); return 1; @@ -397,7 +402,9 @@ export class UpdateCommand extends Command { if (options.migrateOnly) { if (!options.from && typeof options.migrateOnly !== 'string') { - this.logger.error('"from" option is required when using the "migrate-only" option without a migration name.'); + this.logger.error( + '"from" option is required when using the "migrate-only" option without a migration name.', + ); return 1; } else if (packages.length !== 1) { @@ -460,8 +467,7 @@ export class UpdateCommand extends Command { if (migrations.startsWith('../')) { this.logger.error( - 'Package contains an invalid migrations field. ' + - 'Paths outside the package root are not permitted.', + 'Package contains an invalid migrations field. Paths outside the package root are not permitted.', ); return 1; @@ -487,9 +493,9 @@ export class UpdateCommand extends Command { } } - let success = false; + let result: boolean; if (typeof options.migrateOnly == 'string') { - success = await this.executeMigration( + result = await this.executeMigration( packageName, migrations, options.migrateOnly, @@ -506,8 +512,7 @@ export class UpdateCommand extends Command { const migrationRange = new semver.Range( '>' + from + ' <=' + (options.to || packageNode.version), ); - - success = await this.executeMigrations( + result = await this.executeMigrations( packageName, migrations, migrationRange, @@ -515,20 +520,7 @@ export class UpdateCommand extends Command { ); } - if (success) { - if ( - packageName === '@angular/core' - && options.from - && +options.from.split('.')[0] < 9 - && (options.to || packageNode.version).split('.')[0] === '9' - ) { - this.logger.info(NG_VERSION_9_POST_MSG); - } - - return 0; - } - - return 1; + return result ? 0 : 1; } const requests: { @@ -623,6 +615,35 @@ export class UpdateCommand extends Command { continue; } + if (node.package && ANGULAR_PACKAGES_REGEXP.test(node.package.name)) { + const { name, version } = node.package; + const toBeInstalledMajorVersion = +manifest.version.split('.')[0]; + const currentMajorVersion = +version.split('.')[0]; + + if (toBeInstalledMajorVersion - currentMajorVersion > 1) { + // Only allow updating a single version at a time. + if (currentMajorVersion < 6) { + // Before version 6, the major versions were not always sequential. + // Example @angular/core skipped version 3, @angular/cli skipped versions 2-5. + this.logger.error( + `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + + `For more information about the update process, see https://update.angular.io/.`, + ); + } else { + const nextMajorVersionFromCurrent = currentMajorVersion + 1; + + this.logger.error( + `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + + `Run 'ng update ${name}@${nextMajorVersionFromCurrent}' in your workspace directory ` + + `to update to latest '${nextMajorVersionFromCurrent}.x' version of '${name}'.\n\n` + + `For more information about the update process, see https://update.angular.io/?v=${currentMajorVersion}.0-${nextMajorVersionFromCurrent}.0`, + ); + } + + return 1; + } + } + packagesToUpdate.push(requestIdentifier.toString()); } @@ -641,7 +662,8 @@ export class UpdateCommand extends Command { if (success && options.createCommits) { const committed = this.commit( - `Angular CLI update for packages - ${packagesToUpdate.join(', ')}`); + `Angular CLI update for packages - ${packagesToUpdate.join(', ')}`, + ); if (!committed) { return 1; } @@ -730,10 +752,6 @@ export class UpdateCommand extends Command { return 0; } } - - if (migrations.some(m => m.package === '@angular/core' && m.to.split('.')[0] === '9' && +m.from.split('.')[0] < 9)) { - this.logger.info(NG_VERSION_9_POST_MSG); - } } return success ? 0 : 1; @@ -763,8 +781,7 @@ export class UpdateCommand extends Command { try { createCommit(message); } catch (err) { - this.logger.error( - `Failed to commit update (${message}):\n${err.stderr}`); + this.logger.error(`Failed to commit update (${message}):\n${err.stderr}`); return false; } @@ -773,8 +790,7 @@ export class UpdateCommand extends Command { const hash = findCurrentGitSha(); const shortMessage = message.split('\n')[0]; if (hash) { - this.logger.info(` Committed migration step (${getShortHash(hash)}): ${ - shortMessage}.`); + this.logger.info(` Committed migration step (${getShortHash(hash)}): ${shortMessage}.`); } else { // Commit was successful, but reading the hash was not. Something weird happened, // but nothing that would stop the update. Just log the weirdness and continue. @@ -787,7 +803,10 @@ export class UpdateCommand extends Command { private checkCleanGit(): boolean { try { - const topLevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: 'pipe' }); + const topLevel = execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + stdio: 'pipe', + }); const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' }); if (result.trim().length === 0) { return true; @@ -810,14 +829,16 @@ export class UpdateCommand extends Command { } /** - * Checks if the current installed CLI version is older than the latest version. - * @returns `true` when the installed version is older. - */ - private async checkCLILatestVersion(verbose = false, next = false): Promise { - const { version: installedCLIVersion } = require('../package.json'); - - const LatestCLIManifest = await fetchPackageManifest( - `@angular/cli@${next ? 'next' : 'latest'}`, + * Checks if the current installed CLI version is older or newer than a compatible version. + * @returns the version to install or null when there is no update to install. + */ + private async checkCLIVersion( + packagesToUpdate: string[] | undefined, + verbose = false, + next = false, + ): Promise { + const { version } = await fetchPackageManifest( + `@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`, this.logger, { verbose, @@ -825,7 +846,38 @@ export class UpdateCommand extends Command { }, ); - return semver.lt(installedCLIVersion, LatestCLIManifest.version); + return VERSION.full === version ? null : version; + } + + private getCLIUpdateRunnerVersion( + packagesToUpdate: string[] | undefined, + next: boolean, + ): string | number { + if (next) { + return 'next'; + } + + const updatingAngularPackage = packagesToUpdate?.find((r) => ANGULAR_PACKAGES_REGEXP.test(r)); + if (updatingAngularPackage) { + // If we are updating any Angular package we can update the CLI to the target version because + // migrations for @angular/core@13 can be executed using Angular/cli@13. + // This is same behaviour as `npx @angular/cli@13 update @angular/core@13`. + + // `@angular/cli@13` -> ['', 'angular/cli', '13'] + // `@angular/cli` -> ['', 'angular/cli'] + const tempVersion = coerceVersionNumber(updatingAngularPackage.split('@')[2]); + + return semver.parse(tempVersion)?.major ?? 'latest'; + } + + // When not updating an Angular package we cannot determine which schematic runtime the migration should to be executed in. + // Typically, we can assume that the `@angular/cli` was updated previously. + // Example: Angular official packages are typically updated prior to NGRX etc... + // Therefore, we only update to the latest patch version of the installed major version of the Angular CLI. + + // This is important because we might end up in a scenario where locally Angular v12 is installed, updating NGRX from 11 to 12. + // We end up using Angular ClI v13 to run the migrations if we run the migrations using the CLI installed major version + 1 logic. + return VERSION.major; } } @@ -858,7 +910,7 @@ function createCommit(message: string) { */ function findCurrentGitSha(): string | null { try { - const hash = execSync('git rev-parse HEAD', {encoding: 'utf8', stdio: 'pipe'}); + const hash = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }); return hash.trim(); } catch { diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index cc3f5a00b229..7a67dabea1c8 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -108,7 +108,11 @@ if (process.env['NG_CLI_PROFILING']) { if (isGlobalGreater) { // If using the update command and the global version is greater, use the newer update command // This allows improvements in update to be used in older versions that do not have bootstrapping - if (process.argv[2] === 'update') { + if ( + process.argv[2] === 'update' && + cli.VERSION && + cli.VERSION.major - globalVersion.major <= 1 + ) { cli = await import('./cli'); } else if (await isWarningEnabled('versionMismatch')) { // Otherwise, use local version and warn if global is newer than local diff --git a/packages/schematics/update/update/index.ts b/packages/schematics/update/update/index.ts index a0ce8cbd7343..e30b99ab9e76 100644 --- a/packages/schematics/update/update/index.ts +++ b/packages/schematics/update/update/index.ts @@ -459,10 +459,39 @@ function _usageMessage( const packageGroups = new Map(); const packagesToUpdate = [...infoMap.entries()] .map(([name, info]) => { - const tag = options.next - ? (info.npmPackageJson['dist-tags']['next'] ? 'next' : 'latest') : 'latest'; - const version = info.npmPackageJson['dist-tags'][tag]; - const target = info.npmPackageJson.versions[version]; + let tag = options.next + ? info.npmPackageJson['dist-tags']['next'] + ? 'next' + : 'latest' + : 'latest'; + let version = info.npmPackageJson['dist-tags'][tag]; + let target = info.npmPackageJson.versions[version]; + + const versionDiff = semver.diff(info.installed.version, version); + if ( + versionDiff !== 'patch' && + versionDiff !== 'minor' && + /^@(?:angular|nguniversal)\//.test(name) + ) { + const installedMajorVersion = semver.parse(info.installed.version)?.major; + const toInstallMajorVersion = semver.parse(version)?.major; + if ( + installedMajorVersion !== undefined && + toInstallMajorVersion !== undefined && + installedMajorVersion < toInstallMajorVersion - 1 + ) { + const nextMajorVersion = `${installedMajorVersion + 1}.`; + const nextMajorVersions = Object.keys(info.npmPackageJson.versions) + .filter((v) => v.startsWith(nextMajorVersion)) + .sort((a, b) => (a > b ? -1 : 1)); + + if (nextMajorVersions.length) { + version = nextMajorVersions[0]; + target = info.npmPackageJson.versions[version]; + tag = ''; + } + } + } return { name, @@ -496,7 +525,9 @@ function _usageMessage( } let command = `ng update ${name}`; - if (tag == 'next') { + if (!tag) { + command += `@${semver.parse(version)?.major || version}`; + } else if (tag == 'next') { command += ' --next'; } diff --git a/tests/legacy-cli/e2e/tests/build/material.ts b/tests/legacy-cli/e2e/tests/build/material.ts index 4ee43b0f5a30..b4c4c8ee6650 100644 --- a/tests/legacy-cli/e2e/tests/build/material.ts +++ b/tests/legacy-cli/e2e/tests/build/material.ts @@ -28,7 +28,7 @@ export default async function () { await installWorkspacePackages(); } else { - await installPackage('@angular/material-moment-adapter'); + await installPackage('@angular/material-moment-adapter@11'); } await installPackage('moment'); diff --git a/tests/legacy-cli/e2e/tests/commands/add/version-specifier.ts b/tests/legacy-cli/e2e/tests/commands/add/version-specifier.ts index df4903d3c29d..3e4eae89b7f3 100644 --- a/tests/legacy-cli/e2e/tests/commands/add/version-specifier.ts +++ b/tests/legacy-cli/e2e/tests/commands/add/version-specifier.ts @@ -18,11 +18,6 @@ export default async function () { throw new Error('Installation was not skipped'); } - const output2 = await ng('add', '@angular/localize@latest'); - if (output2.stdout.includes('Skipping installation: Package already installed')) { - throw new Error('Installation should not have been skipped'); - } - const output3 = await ng('add', '@angular/localize@10.0.0'); if (output3.stdout.includes('Skipping installation: Package already installed')) { throw new Error('Installation should not have been skipped'); diff --git a/tests/legacy-cli/e2e/tests/misc/npm-7.ts b/tests/legacy-cli/e2e/tests/misc/npm-7.ts index 7c7f278858fe..1cf2fe9840bf 100644 --- a/tests/legacy-cli/e2e/tests/misc/npm-7.ts +++ b/tests/legacy-cli/e2e/tests/misc/npm-7.ts @@ -1,11 +1,12 @@ -import { rimraf, writeFile } from '../../utils/fs'; +import { rimraf } from '../../utils/fs'; import { getActivePackageManager } from '../../utils/packages'; import { ng, npm } from '../../utils/process'; +import { isPrereleaseCli } from '../../utils/project'; import { expectToFail } from '../../utils/utils'; const warningText = 'npm version 7.5.6 or higher is recommended'; -export default async function() { +export default async function () { // Only relevant with npm as a package manager if (getActivePackageManager() !== 'npm') { return; @@ -17,12 +18,18 @@ export default async function() { } const currentDirectory = process.cwd(); + + const extraArgs = []; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + try { // Install version >=7.5.6 await npm('install', '--global', 'npm@>=7.5.6'); // Ensure `ng update` does not show npm warning - const { stderr: stderrUpdate1 } = await ng('update'); + const { stderr: stderrUpdate1 } = await ng('update', ...extraArgs); if (stderrUpdate1.includes(warningText)) { throw new Error('ng update expected to not show npm version warning.'); } @@ -37,7 +44,7 @@ export default async function() { } // Ensure `ng update` shows npm warning - const { stderr: stderrUpdate2 } = await ng('update'); + const { stderr: stderrUpdate2 } = await ng('update', ...extraArgs); if (!stderrUpdate2.includes(warningText)) { throw new Error('ng update expected to show npm version warning.'); } @@ -85,5 +92,4 @@ export default async function() { // Reset version back to 6.x await npm('install', '--global', 'npm@6'); } - }