Skip to content

Commit 9d6891f

Browse files
committed
refactor(@angular/cli): split update command into smaller modules
This commit refactors the `ng update` command by extracting functions from `packages/angular/cli/src/commands/update/cli.ts` into more focused utility modules: - `packages/angular/cli/src/commands/update/utilities/git.ts`: Encapsulates Git-related operations such as checking for a clean repository, creating commits, and retrieving commit hashes. - `packages/angular/cli/src/commands/update/utilities/cli-version.ts`: Handles CLI version compatibility checks, temporary CLI installations, and package manager force options. - `packages/angular/cli/src/commands/update/utilities/constants.ts`: Stores shared constants like `ANGULAR_PACKAGES_REGEXP`.
1 parent 9a8ea6b commit 9d6891f

File tree

4 files changed

+337
-228
lines changed

4 files changed

+337
-228
lines changed

packages/angular/cli/src/commands/update/cli.ts

Lines changed: 33 additions & 228 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ import {
1313
NodeWorkflow,
1414
} from '@angular-devkit/schematics/tools';
1515
import { Listr } from 'listr2';
16-
import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process';
16+
import { SpawnSyncReturns } from 'node:child_process';
1717
import { existsSync, promises as fs } from 'node:fs';
1818
import { createRequire } from 'node:module';
1919
import * as path from 'node:path';
20-
import { join, resolve } from 'node:path';
2120
import npa from 'npm-package-arg';
2221
import * as semver from 'semver';
2322
import { Argv } from 'yargs';
24-
import { PackageManager } from '../../../lib/config/workspace-schema';
2523
import {
2624
CommandModule,
2725
CommandModuleError,
@@ -37,7 +35,6 @@ import { writeErrorToLogFile } from '../../utilities/log-file';
3735
import {
3836
PackageIdentifier,
3937
PackageManifest,
40-
fetchPackageManifest,
4138
fetchPackageMetadata,
4239
} from '../../utilities/package-metadata';
4340
import {
@@ -48,7 +45,20 @@ import {
4845
} from '../../utilities/package-tree';
4946
import { askChoices } from '../../utilities/prompt';
5047
import { isTTY } from '../../utilities/tty';
51-
import { VERSION } from '../../utilities/version';
48+
import {
49+
checkCLIVersion,
50+
coerceVersionNumber,
51+
runTempBinary,
52+
shouldForcePackageManager,
53+
} from './utilities/cli-version';
54+
import { ANGULAR_PACKAGES_REGEXP } from './utilities/constants';
55+
import {
56+
checkCleanGit,
57+
createCommit,
58+
findCurrentGitSha,
59+
getShortHash,
60+
hasChangesToCommit,
61+
} from './utilities/git';
5262

5363
interface UpdateCommandArgs {
5464
packages?: string[];
@@ -63,8 +73,10 @@ interface UpdateCommandArgs {
6373
'create-commits': boolean;
6474
}
6575

66-
interface MigrationSchematicDescription
67-
extends SchematicDescription<FileSystemCollectionDescription, FileSystemSchematicDescription> {
76+
interface MigrationSchematicDescription extends SchematicDescription<
77+
FileSystemCollectionDescription,
78+
FileSystemSchematicDescription
79+
> {
6880
version?: string;
6981
optional?: boolean;
7082
recommended?: boolean;
@@ -77,7 +89,6 @@ interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDes
7789

7890
class CommandError extends Error {}
7991

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

8394
export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs> {
@@ -87,7 +98,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
8798

8899
command = 'update [packages..]';
89100
describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.';
90-
longDescriptionPath = join(__dirname, 'long-description.md');
101+
longDescriptionPath = path.join(__dirname, 'long-description.md');
91102

92103
builder(localYargs: Argv): Argv<UpdateCommandArgs> {
93104
return localYargs
@@ -161,7 +172,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
161172
const { logger } = this.context;
162173

163174
// This allows the user to easily reset any changes from the update.
164-
if (packages?.length && !this.checkCleanGit()) {
175+
if (packages?.length && !checkCleanGit(this.context.root)) {
165176
if (allowDirty) {
166177
logger.warn(
167178
'Repository is not clean. Update changes will be mixed with pre-existing changes.',
@@ -192,8 +203,10 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
192203
// Check if the current installed CLI version is older than the latest compatible version.
193204
// Skip when running `ng update` without a package name as this will not trigger an actual update.
194205
if (!disableVersionCheck && options.packages?.length) {
195-
const cliVersionToInstall = await this.checkCLIVersion(
206+
const cliVersionToInstall = await checkCLIVersion(
196207
options.packages,
208+
logger,
209+
packageManager,
197210
options.verbose,
198211
options.next,
199212
);
@@ -204,7 +217,11 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
204217
`Installing a temporary Angular CLI versioned ${cliVersionToInstall} to perform the update.`,
205218
);
206219

207-
return this.runTempBinary(`@angular/cli@${cliVersionToInstall}`, process.argv.slice(2));
220+
return runTempBinary(
221+
`@angular/cli@${cliVersionToInstall}`,
222+
packageManager,
223+
process.argv.slice(2),
224+
);
208225
}
209226
}
210227

@@ -254,7 +271,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
254271

255272
const workflow = new NodeWorkflow(this.context.root, {
256273
packageManager: packageManager.name,
257-
packageManagerForce: this.packageManagerForce(options.verbose),
274+
packageManagerForce: shouldForcePackageManager(packageManager, logger, options.verbose),
258275
// __dirname -> favor @schematics/update from this package
259276
// Otherwise, use packages from the active workspace (migrations)
260277
resolvePaths: this.resolvePaths,
@@ -771,7 +788,9 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
771788

772789
if (success) {
773790
const { root: commandRoot, packageManager } = this.context;
774-
const installArgs = this.packageManagerForce(options.verbose) ? ['--force'] : [];
791+
const installArgs = shouldForcePackageManager(packageManager, logger, options.verbose)
792+
? ['--force']
793+
: [];
775794
const tasks = new Listr([
776795
{
777796
title: 'Cleaning node modules directory',
@@ -961,158 +980,6 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
961980
return true;
962981
}
963982

964-
private checkCleanGit(): boolean {
965-
try {
966-
const topLevel = execSync('git rev-parse --show-toplevel', {
967-
encoding: 'utf8',
968-
stdio: 'pipe',
969-
});
970-
const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' });
971-
if (result.trim().length === 0) {
972-
return true;
973-
}
974-
975-
// Only files inside the workspace root are relevant
976-
for (const entry of result.split('\n')) {
977-
const relativeEntry = path.relative(
978-
path.resolve(this.context.root),
979-
path.resolve(topLevel.trim(), entry.slice(3).trim()),
980-
);
981-
982-
if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) {
983-
return false;
984-
}
985-
}
986-
} catch {}
987-
988-
return true;
989-
}
990-
991-
/**
992-
* Checks if the current installed CLI version is older or newer than a compatible version.
993-
* @returns the version to install or null when there is no update to install.
994-
*/
995-
private async checkCLIVersion(
996-
packagesToUpdate: string[],
997-
verbose = false,
998-
next = false,
999-
): Promise<string | null> {
1000-
const { version } = await fetchPackageManifest(
1001-
`@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`,
1002-
this.context.logger,
1003-
{
1004-
verbose,
1005-
usingYarn: this.context.packageManager.name === PackageManager.Yarn,
1006-
},
1007-
);
1008-
1009-
return VERSION.full === version ? null : version;
1010-
}
1011-
1012-
private getCLIUpdateRunnerVersion(
1013-
packagesToUpdate: string[] | undefined,
1014-
next: boolean,
1015-
): string | number {
1016-
if (next) {
1017-
return 'next';
1018-
}
1019-
1020-
const updatingAngularPackage = packagesToUpdate?.find((r) => ANGULAR_PACKAGES_REGEXP.test(r));
1021-
if (updatingAngularPackage) {
1022-
// If we are updating any Angular package we can update the CLI to the target version because
1023-
// migrations for @angular/core@13 can be executed using Angular/cli@13.
1024-
// This is same behaviour as `npx @angular/cli@13 update @angular/core@13`.
1025-
1026-
// `@angular/cli@13` -> ['', 'angular/cli', '13']
1027-
// `@angular/cli` -> ['', 'angular/cli']
1028-
const tempVersion = coerceVersionNumber(updatingAngularPackage.split('@')[2]);
1029-
1030-
return semver.parse(tempVersion)?.major ?? 'latest';
1031-
}
1032-
1033-
// When not updating an Angular package we cannot determine which schematic runtime the migration should to be executed in.
1034-
// Typically, we can assume that the `@angular/cli` was updated previously.
1035-
// Example: Angular official packages are typically updated prior to NGRX etc...
1036-
// Therefore, we only update to the latest patch version of the installed major version of the Angular CLI.
1037-
1038-
// This is important because we might end up in a scenario where locally Angular v12 is installed, updating NGRX from 11 to 12.
1039-
// We end up using Angular ClI v13 to run the migrations if we run the migrations using the CLI installed major version + 1 logic.
1040-
return VERSION.major;
1041-
}
1042-
1043-
private async runTempBinary(packageName: string, args: string[] = []): Promise<number> {
1044-
const { success, tempNodeModules } = await this.context.packageManager.installTemp(packageName);
1045-
if (!success) {
1046-
return 1;
1047-
}
1048-
1049-
// Remove version/tag etc... from package name
1050-
// Ex: @angular/cli@latest -> @angular/cli
1051-
const packageNameNoVersion = packageName.substring(0, packageName.lastIndexOf('@'));
1052-
const pkgLocation = join(tempNodeModules, packageNameNoVersion);
1053-
const packageJsonPath = join(pkgLocation, 'package.json');
1054-
1055-
// Get a binary location for this package
1056-
let binPath: string | undefined;
1057-
if (existsSync(packageJsonPath)) {
1058-
const content = await fs.readFile(packageJsonPath, 'utf-8');
1059-
if (content) {
1060-
const { bin = {} } = JSON.parse(content) as { bin: Record<string, string> };
1061-
const binKeys = Object.keys(bin);
1062-
1063-
if (binKeys.length) {
1064-
binPath = resolve(pkgLocation, bin[binKeys[0]]);
1065-
}
1066-
}
1067-
}
1068-
1069-
if (!binPath) {
1070-
throw new Error(`Cannot locate bin for temporary package: ${packageNameNoVersion}.`);
1071-
}
1072-
1073-
const { status, error } = spawnSync(process.execPath, [binPath, ...args], {
1074-
stdio: 'inherit',
1075-
env: {
1076-
...process.env,
1077-
NG_DISABLE_VERSION_CHECK: 'true',
1078-
NG_CLI_ANALYTICS: 'false',
1079-
},
1080-
});
1081-
1082-
if (status === null && error) {
1083-
throw error;
1084-
}
1085-
1086-
return status ?? 0;
1087-
}
1088-
1089-
private packageManagerForce(verbose: boolean): boolean {
1090-
// npm 7+ can fail due to it incorrectly resolving peer dependencies that have valid SemVer
1091-
// ranges during an update. Update will set correct versions of dependencies within the
1092-
// package.json file. The force option is set to workaround these errors.
1093-
// Example error:
1094-
// npm ERR! Conflicting peer dependency: @angular/compiler-cli@14.0.0-rc.0
1095-
// npm ERR! node_modules/@angular/compiler-cli
1096-
// npm ERR! peer @angular/compiler-cli@"^14.0.0 || ^14.0.0-rc" from @angular-devkit/build-angular@14.0.0-rc.0
1097-
// npm ERR! node_modules/@angular-devkit/build-angular
1098-
// npm ERR! dev @angular-devkit/build-angular@"~14.0.0-rc.0" from the root project
1099-
if (
1100-
this.context.packageManager.name === PackageManager.Npm &&
1101-
this.context.packageManager.version &&
1102-
semver.gte(this.context.packageManager.version, '7.0.0')
1103-
) {
1104-
if (verbose) {
1105-
this.context.logger.info(
1106-
'NPM 7+ detected -- enabling force option for package installation',
1107-
);
1108-
}
1109-
1110-
return true;
1111-
}
1112-
1113-
return false;
1114-
}
1115-
1116983
private async getOptionalMigrationsToRun(
1117984
optionalMigrations: MigrationSchematicDescription[],
1118985
packageName: string,
@@ -1161,68 +1028,6 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
11611028
}
11621029
}
11631030

1164-
/**
1165-
* @return Whether or not the working directory has Git changes to commit.
1166-
*/
1167-
function hasChangesToCommit(): boolean {
1168-
// List all modified files not covered by .gitignore.
1169-
// If any files are returned, then there must be something to commit.
1170-
1171-
return execSync('git ls-files -m -d -o --exclude-standard').toString() !== '';
1172-
}
1173-
1174-
/**
1175-
* Precondition: Must have pending changes to commit, they do not need to be staged.
1176-
* Postcondition: The Git working tree is committed and the repo is clean.
1177-
* @param message The commit message to use.
1178-
*/
1179-
function createCommit(message: string) {
1180-
// Stage entire working tree for commit.
1181-
execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' });
1182-
1183-
// Commit with the message passed via stdin to avoid bash escaping issues.
1184-
execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message });
1185-
}
1186-
1187-
/**
1188-
* @return The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash.
1189-
*/
1190-
function findCurrentGitSha(): string | null {
1191-
try {
1192-
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }).trim();
1193-
} catch {
1194-
return null;
1195-
}
1196-
}
1197-
1198-
function getShortHash(commitHash: string): string {
1199-
return commitHash.slice(0, 9);
1200-
}
1201-
1202-
function coerceVersionNumber(version: string | undefined): string | undefined {
1203-
if (!version) {
1204-
return undefined;
1205-
}
1206-
1207-
if (!/^\d{1,30}\.\d{1,30}\.\d{1,30}/.test(version)) {
1208-
const match = version.match(/^\d{1,30}(\.\d{1,30})*/);
1209-
1210-
if (!match) {
1211-
return undefined;
1212-
}
1213-
1214-
if (!match[1]) {
1215-
version = version.substring(0, match[0].length) + '.0.0' + version.substring(match[0].length);
1216-
} else if (!match[2]) {
1217-
version = version.substring(0, match[0].length) + '.0' + version.substring(match[0].length);
1218-
} else {
1219-
return undefined;
1220-
}
1221-
}
1222-
1223-
return semver.valid(version) ?? undefined;
1224-
}
1225-
12261031
function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): {
12271032
title: string;
12281033
description: string;

0 commit comments

Comments
 (0)