diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index fdf8e850e026..50442ade0d35 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -13,15 +13,13 @@ import { NodeWorkflow, } from '@angular-devkit/schematics/tools'; import { Listr } from 'listr2'; -import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process'; +import { SpawnSyncReturns } from 'node:child_process'; import { existsSync, promises as fs } from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { join, resolve } from 'node:path'; import npa from 'npm-package-arg'; import * as semver from 'semver'; import { Argv } from 'yargs'; -import { PackageManager } from '../../../lib/config/workspace-schema'; import { CommandModule, CommandModuleError, @@ -37,7 +35,6 @@ import { writeErrorToLogFile } from '../../utilities/log-file'; import { PackageIdentifier, PackageManifest, - fetchPackageManifest, fetchPackageMetadata, } from '../../utilities/package-metadata'; import { @@ -48,7 +45,20 @@ import { } from '../../utilities/package-tree'; import { askChoices } from '../../utilities/prompt'; import { isTTY } from '../../utilities/tty'; -import { VERSION } from '../../utilities/version'; +import { + checkCLIVersion, + coerceVersionNumber, + runTempBinary, + shouldForcePackageManager, +} from './utilities/cli-version'; +import { ANGULAR_PACKAGES_REGEXP } from './utilities/constants'; +import { + checkCleanGit, + createCommit, + findCurrentGitSha, + getShortHash, + hasChangesToCommit, +} from './utilities/git'; interface UpdateCommandArgs { packages?: string[]; @@ -63,8 +73,10 @@ interface UpdateCommandArgs { 'create-commits': boolean; } -interface MigrationSchematicDescription - extends SchematicDescription { +interface MigrationSchematicDescription extends SchematicDescription< + FileSystemCollectionDescription, + FileSystemSchematicDescription +> { version?: string; optional?: boolean; recommended?: boolean; @@ -77,7 +89,6 @@ interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDes class CommandError extends Error {} -const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json'); export default class UpdateCommandModule extends CommandModule { @@ -87,7 +98,7 @@ export default class UpdateCommandModule extends CommandModule { return localYargs @@ -161,7 +172,7 @@ export default class UpdateCommandModule extends CommandModule favor @schematics/update from this package // Otherwise, use packages from the active workspace (migrations) resolvePaths: this.resolvePaths, @@ -771,7 +788,9 @@ export default class UpdateCommandModule extends CommandModule { - const { version } = await fetchPackageManifest( - `@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`, - this.context.logger, - { - verbose, - usingYarn: this.context.packageManager.name === PackageManager.Yarn, - }, - ); - - 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; - } - - private async runTempBinary(packageName: string, args: string[] = []): Promise { - const { success, tempNodeModules } = await this.context.packageManager.installTemp(packageName); - if (!success) { - return 1; - } - - // Remove version/tag etc... from package name - // Ex: @angular/cli@latest -> @angular/cli - const packageNameNoVersion = packageName.substring(0, packageName.lastIndexOf('@')); - const pkgLocation = join(tempNodeModules, packageNameNoVersion); - const packageJsonPath = join(pkgLocation, 'package.json'); - - // Get a binary location for this package - let binPath: string | undefined; - if (existsSync(packageJsonPath)) { - const content = await fs.readFile(packageJsonPath, 'utf-8'); - if (content) { - const { bin = {} } = JSON.parse(content) as { bin: Record }; - const binKeys = Object.keys(bin); - - if (binKeys.length) { - binPath = resolve(pkgLocation, bin[binKeys[0]]); - } - } - } - - if (!binPath) { - throw new Error(`Cannot locate bin for temporary package: ${packageNameNoVersion}.`); - } - - const { status, error } = spawnSync(process.execPath, [binPath, ...args], { - stdio: 'inherit', - env: { - ...process.env, - NG_DISABLE_VERSION_CHECK: 'true', - NG_CLI_ANALYTICS: 'false', - }, - }); - - if (status === null && error) { - throw error; - } - - return status ?? 0; - } - - private packageManagerForce(verbose: boolean): boolean { - // npm 7+ can fail due to it incorrectly resolving peer dependencies that have valid SemVer - // ranges during an update. Update will set correct versions of dependencies within the - // package.json file. The force option is set to workaround these errors. - // Example error: - // npm ERR! Conflicting peer dependency: @angular/compiler-cli@14.0.0-rc.0 - // npm ERR! node_modules/@angular/compiler-cli - // npm ERR! peer @angular/compiler-cli@"^14.0.0 || ^14.0.0-rc" from @angular-devkit/build-angular@14.0.0-rc.0 - // npm ERR! node_modules/@angular-devkit/build-angular - // npm ERR! dev @angular-devkit/build-angular@"~14.0.0-rc.0" from the root project - if ( - this.context.packageManager.name === PackageManager.Npm && - this.context.packageManager.version && - semver.gte(this.context.packageManager.version, '7.0.0') - ) { - if (verbose) { - this.context.logger.info( - 'NPM 7+ detected -- enabling force option for package installation', - ); - } - - return true; - } - - return false; - } - private async getOptionalMigrationsToRun( optionalMigrations: MigrationSchematicDescription[], packageName: string, @@ -1161,68 +1028,6 @@ export default class UpdateCommandModule extends CommandModule { + const { version } = await fetchPackageManifest( + `@angular/cli@${getCLIUpdateRunnerVersion(packagesToUpdate, next)}`, + logger, + { + verbose, + usingYarn: packageManager.name === PackageManager.Yarn, + }, + ); + + return VERSION.full === version ? null : version; +} + +/** + * Determines the version of the CLI to use for the update process. + * @param packagesToUpdate The list of packages being updated. + * @param next Whether to use the next version. + * @returns The version or tag to use for the CLI update runner. + */ +export function 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; +} + +/** + * Runs a binary from a temporary package installation. + * @param packageName The name of the package to install and run. + * @param packageManager The package manager instance. + * @param args The arguments to pass to the binary. + * @returns The exit code of the binary. + */ +export async function runTempBinary( + packageName: string, + packageManager: PackageManagerUtils, + args: string[] = [], +): Promise { + const { success, tempNodeModules } = await packageManager.installTemp(packageName); + if (!success) { + return 1; + } + + // Remove version/tag etc... from package name + // Ex: @angular/cli@latest -> @angular/cli + const packageNameNoVersion = packageName.substring(0, packageName.lastIndexOf('@')); + const pkgLocation = join(tempNodeModules, packageNameNoVersion); + const packageJsonPath = join(pkgLocation, 'package.json'); + + // Get a binary location for this package + let binPath: string | undefined; + if (existsSync(packageJsonPath)) { + const content = await fs.readFile(packageJsonPath, 'utf-8'); + if (content) { + const { bin = {} } = JSON.parse(content) as { bin: Record }; + const binKeys = Object.keys(bin); + + if (binKeys.length) { + binPath = resolve(pkgLocation, bin[binKeys[0]]); + } + } + } + + if (!binPath) { + throw new Error(`Cannot locate bin for temporary package: ${packageNameNoVersion}.`); + } + + const { status, error } = spawnSync(process.execPath, [binPath, ...args], { + stdio: 'inherit', + env: { + ...process.env, + NG_DISABLE_VERSION_CHECK: 'true', + NG_CLI_ANALYTICS: 'false', + }, + }); + + if (status === null && error) { + throw error; + } + + return status ?? 0; +} + +/** + * Determines whether to force the package manager to ignore peer dependency warnings. + * @param packageManager The package manager instance. + * @param logger The logger instance. + * @param verbose Whether to log verbose output. + * @returns True if the package manager should be forced, false otherwise. + */ +export function shouldForcePackageManager( + packageManager: PackageManagerUtils, + logger: logging.LoggerApi, + verbose: boolean, +): boolean { + // npm 7+ can fail due to it incorrectly resolving peer dependencies that have valid SemVer + // ranges during an update. Update will set correct versions of dependencies within the + // package.json file. The force option is set to workaround these errors. + // Example error: + // npm ERR! Conflicting peer dependency: @angular/compiler-cli@14.0.0-rc.0 + // npm ERR! node_modules/@angular/compiler-cli + // npm ERR! peer @angular/compiler-cli@"^14.0.0 || ^14.0.0-rc" from @angular-devkit/build-angular@14.0.0-rc.0 + // npm ERR! node_modules/@angular-devkit/build-angular + // npm ERR! dev @angular-devkit/build-angular@"~14.0.0-rc.0" from the root project + if ( + packageManager.name === PackageManager.Npm && + packageManager.version && + semver.gte(packageManager.version, '7.0.0') + ) { + if (verbose) { + logger.info('NPM 7+ detected -- enabling force option for package installation'); + } + + return true; + } + + return false; +} diff --git a/packages/angular/cli/src/commands/update/utilities/constants.ts b/packages/angular/cli/src/commands/update/utilities/constants.ts new file mode 100644 index 000000000000..dfeef5ff96d1 --- /dev/null +++ b/packages/angular/cli/src/commands/update/utilities/constants.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Regular expression to match Angular packages. + * Checks for packages starting with `@angular/` or `@nguniversal/`. + */ +export const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; diff --git a/packages/angular/cli/src/commands/update/utilities/git.ts b/packages/angular/cli/src/commands/update/utilities/git.ts new file mode 100644 index 000000000000..631e5b9bb99e --- /dev/null +++ b/packages/angular/cli/src/commands/update/utilities/git.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execSync } from 'node:child_process'; +import * as path from 'node:path'; + +/** + * Checks if the git repository is clean. + * @param root The root directory of the project. + * @returns True if the repository is clean, false otherwise. + */ +export function checkCleanGit(root: string): boolean { + try { + 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; + } + + // Only files inside the workspace root are relevant + for (const entry of result.split('\n')) { + const relativeEntry = path.relative( + path.resolve(root), + path.resolve(topLevel.trim(), entry.slice(3).trim()), + ); + + if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) { + return false; + } + } + } catch {} // eslint-disable-line no-empty + + return true; +} + +/** + * Checks if the working directory has pending changes to commit. + * @returns Whether or not the working directory has Git changes to commit. + */ +export function hasChangesToCommit(): boolean { + // List all modified files not covered by .gitignore. + // If any files are returned, then there must be something to commit. + + return execSync('git ls-files -m -d -o --exclude-standard').toString() !== ''; +} + +/** + * Stages all changes in the Git working tree and creates a new commit. + * @param message The commit message to use. + */ +export function createCommit(message: string) { + // Stage entire working tree for commit. + execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' }); + + // Commit with the message passed via stdin to avoid bash escaping issues. + execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message }); +} + +/** + * Finds the Git SHA hash of the HEAD commit. + * @returns The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash. + */ +export function findCurrentGitSha(): string | null { + try { + return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }).trim(); + } catch { + return null; + } +} + +/** + * Gets the short hash of a commit. + * @param commitHash The full commit hash. + * @returns The short hash (first 9 characters). + */ +export function getShortHash(commitHash: string): string { + return commitHash.slice(0, 9); +}