From 63ee75dfdaf2ac0ff5bd5fe90dd95f727126aee7 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:13:34 -0400 Subject: [PATCH] refactor(@angular/cli): move update version resolution directly to CLI command Removes the @schematics/update:update schematic and moves the package version resolution, group expansion, and peer dependency validation logic directly into the CLI's update command. This simplifies the command execution flow, eliminates sharing state via global variables, and enables direct unit testing of the resolution plan in isolated temporary directories without host monorepo package leakage. --- packages/angular/cli/BUILD.bazel | 7 - .../angular/cli/src/commands/update/cli.ts | 251 ++-- .../commands/update/schematic/collection.json | 9 - .../src/commands/update/schematic/index.ts | 1128 ----------------- .../commands/update/schematic/index_spec.ts | 391 ------ .../src/commands/update/schematic/schema.json | 68 - .../src/commands/update/update-resolver.ts | 1028 +++++++++++++++ .../commands/update/update-resolver_spec.ts | 230 ++++ 8 files changed, 1373 insertions(+), 1739 deletions(-) delete mode 100644 packages/angular/cli/src/commands/update/schematic/collection.json delete mode 100644 packages/angular/cli/src/commands/update/schematic/index.ts delete mode 100644 packages/angular/cli/src/commands/update/schematic/index_spec.ts delete mode 100644 packages/angular/cli/src/commands/update/schematic/schema.json create mode 100644 packages/angular/cli/src/commands/update/update-resolver.ts create mode 100644 packages/angular/cli/src/commands/update/update-resolver_spec.ts diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index eed9ad7360f1..b73ed5fba5fe 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -29,7 +29,6 @@ RUNTIME_ASSETS = glob( include = [ "bin/**/*", "src/**/*.md", - "src/**/*.json", ], exclude = [ "lib/config/workspace-schema.json", @@ -53,7 +52,6 @@ ts_project( ) + [ # These files are generated from the JSON schema "//packages/angular/cli:lib/config/workspace-schema.ts", - "//packages/angular/cli:src/commands/update/schematic/schema.ts", ], data = RUNTIME_ASSETS, deps = [ @@ -105,11 +103,6 @@ ts_json_schema( data = CLI_SCHEMA_DATA, ) -ts_json_schema( - name = "update_schematic_schema", - src = "src/commands/update/schematic/schema.json", -) - ts_project( name = "angular-cli_test_lib", testonly = True, diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 62416b1c1ee7..447782da0616 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -24,6 +24,13 @@ import type { InstalledPackage, PackageManager, PackageManifest } from '../../pa import { colors } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; +import { + UpdatePlan, + applyUpdatePlan, + findPackageJson, + printUpdateUsageMessage, + resolveUserUpdatePlan, +} from './update-resolver'; import { checkCLIVersion, coerceVersionNumber, @@ -32,12 +39,7 @@ import { } from './utilities/cli-version'; import { ANGULAR_PACKAGES_REGEXP } from './utilities/constants'; import { checkCleanGit } from './utilities/git'; -import { - commitChanges, - executeMigration, - executeMigrations, - executeSchematic, -} from './utilities/migration'; +import { commitChanges, executeMigration, executeMigrations } from './utilities/migration'; interface UpdateCommandArgs { packages?: string[]; @@ -54,8 +56,6 @@ interface UpdateCommandArgs { class CommandError extends Error {} -const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json'); - export default class UpdateCommandModule extends CommandModule { override scope = CommandScope.In; protected override shouldReportAnalytics = false; @@ -244,23 +244,28 @@ export default class UpdateCommandModule extends CommandModule string); - -// Angular guarantees that a major is compatible with its following major (so packages that depend -// on Angular 5 are also compatible with Angular 6). This is, in code, represented by verifying -// that all other packages that have a peer dependency of `"@angular/core": "^5.0.0"` actually -// supports 6.0, by adding that compatibility to the range, so it is `^5.0.0 || ^6.0.0`. -// We export it to allow for testing. -export function angularMajorCompatGuarantee(range: string) { - let newRange = semver.validRange(range); - if (!newRange) { - return range; - } - let major = 1; - while (!semver.gtr(major + '.0.0', newRange)) { - major++; - if (major >= 99) { - // Use original range if it supports a major this high - // Range is most likely unbounded (e.g., >=5.0.0) - return newRange; - } - } - - // Add the major version as compatible with the angular compatible, with all minors. This is - // already one major above the greatest supported, because we increment `major` before checking. - // We add minors like this because a minor beta is still compatible with a minor non-beta. - newRange = range; - for (let minor = 0; minor < 20; minor++) { - newRange += ` || ^${major}.${minor}.0-alpha.0 `; - } - - return semver.validRange(newRange) || range; -} - -// This is a map of packageGroupName to range extending function. If it isn't found, the range is -// kept the same. -const knownPeerCompatibleList: { [name: string]: PeerVersionTransform } = { - '@angular/core': angularMajorCompatGuarantee, -}; - -interface PackageVersionInfo { - version: VersionRange; - packageJson: PackageManifest; - updateMetadata: UpdateMetadata; -} - -interface PackageInfo { - name: string; - npmPackageJson: NpmRepositoryPackageJson; - installed: PackageVersionInfo; - target?: PackageVersionInfo; - packageJsonRange: string; -} - -interface UpdateMetadata { - packageGroupName?: string; - packageGroup: { [packageName: string]: string }; - requirements: { [packageName: string]: string }; - migrations?: string; -} - -function _updatePeerVersion(infoMap: Map, name: string, range: string) { - // Resolve packageGroupName. - const maybePackageInfo = infoMap.get(name); - if (!maybePackageInfo) { - return range; - } - if (maybePackageInfo.target) { - name = maybePackageInfo.target.updateMetadata.packageGroupName || name; - } else { - name = maybePackageInfo.installed.updateMetadata.packageGroupName || name; - } - - const maybeTransform = knownPeerCompatibleList[name]; - if (maybeTransform) { - if (typeof maybeTransform == 'function') { - return maybeTransform(range); - } else { - return maybeTransform; - } - } - - return range; -} - -function _validateForwardPeerDependencies( - name: string, - infoMap: Map, - peers: { [name: string]: string }, - peersMeta: { [name: string]: { optional?: boolean } }, - logger: logging.LoggerApi, - next: boolean, -): boolean { - let validationFailed = false; - for (const [peer, range] of Object.entries(peers)) { - logger.debug(`Checking forward peer ${peer}...`); - const maybePeerInfo = infoMap.get(peer); - const isOptional = peersMeta[peer] && !!peersMeta[peer].optional; - if (!maybePeerInfo) { - if (!isOptional) { - logger.warn( - [ - `Package ${JSON.stringify(name)} has a missing peer dependency of`, - `${JSON.stringify(peer)} @ ${JSON.stringify(range)}.`, - ].join(' '), - ); - } - - continue; - } - - const peerVersion = - maybePeerInfo.target && maybePeerInfo.target.packageJson.version - ? maybePeerInfo.target.packageJson.version - : maybePeerInfo.installed.version; - - logger.debug(` Range intersects(${range}, ${peerVersion})...`); - if (!semver.satisfies(peerVersion, range, { includePrerelease: next || undefined })) { - logger.error( - [ - `Package ${JSON.stringify(name)} has an incompatible peer dependency to`, - `${JSON.stringify(peer)} (requires ${JSON.stringify(range)},`, - `would install ${JSON.stringify(peerVersion)})`, - ].join(' '), - ); - - validationFailed = true; - continue; - } - } - - return validationFailed; -} - -function _validateReversePeerDependencies( - name: string, - version: string, - infoMap: Map, - logger: logging.LoggerApi, - next: boolean, -) { - for (const [installed, installedInfo] of infoMap.entries()) { - const installedLogger = logger.createChild(installed); - installedLogger.debug(`${installed}...`); - const peers = (installedInfo.target || installedInfo.installed).packageJson.peerDependencies; - - for (const [peer, range] of Object.entries(peers || {})) { - if (peer != name) { - // Only check peers to the packages we're updating. We don't care about peers - // that are unmet but we have no effect on. - continue; - } - - // Ignore peerDependency mismatches for these packages. - // They are deprecated and removed via a migration. - const ignoredPackages = [ - 'codelyzer', - '@schematics/update', - '@angular-devkit/build-ng-packagr', - 'tsickle', - '@nguniversal/builders', - ]; - if (ignoredPackages.includes(installed)) { - continue; - } - - // Override the peer version range if it's known as a compatible. - const extendedRange = _updatePeerVersion(infoMap, peer, range); - - if (!semver.satisfies(version, extendedRange, { includePrerelease: next || undefined })) { - logger.error( - [ - `Package ${JSON.stringify(installed)} has an incompatible peer dependency to`, - `${JSON.stringify(name)} (requires`, - `${JSON.stringify(range)}${extendedRange == range ? '' : ' (extended)'},`, - `would install ${JSON.stringify(version)}).`, - ].join(' '), - ); - - return true; - } - } - } - - return false; -} - -function _validateUpdatePackages( - infoMap: Map, - force: boolean, - next: boolean, - logger: logging.LoggerApi, -): void { - logger.debug('Updating the following packages:'); - infoMap.forEach((info) => { - if (info.target) { - logger.debug(` ${info.name} => ${info.target.version}`); - } - }); - - let peerErrors = false; - infoMap.forEach((info) => { - const { name, target } = info; - if (!target) { - return; - } - - const pkgLogger = logger.createChild(name); - logger.debug(`${name}...`); - - const { peerDependencies = {}, peerDependenciesMeta = {} } = target.packageJson; - peerErrors = - _validateForwardPeerDependencies( - name, - infoMap, - peerDependencies, - peerDependenciesMeta, - pkgLogger, - next, - ) || peerErrors; - peerErrors = - _validateReversePeerDependencies(name, target.version, infoMap, pkgLogger, next) || - peerErrors; - }); - - if (!force && peerErrors) { - throw new SchematicsException( - 'Incompatible peer dependencies found.\n' + - 'Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.\n' + - `You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`, - ); - } -} - -function _performUpdate( - tree: Tree, - context: SchematicContext, - infoMap: Map, - logger: logging.LoggerApi, - migrateOnly: boolean, -): void { - const packageJsonContent = tree.read('/package.json')?.toString(); - if (!packageJsonContent) { - throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); - } - - const packageJson = tree.readJson('/package.json') as PackageManifest; - - const updateDependency = (deps: Record, name: string, newVersion: string) => { - const oldVersion = deps[name]; - // We only respect caret and tilde ranges on update. - const execResult = /^[\^~]/.exec(oldVersion); - deps[name] = `${execResult ? execResult[0] : ''}${newVersion}`; - }; - - const toInstall = [...infoMap.values()] - .map((x) => [x.name, x.target, x.installed]) - .filter(([name, target, installed]) => { - return !!name && !!target && !!installed; - }) as [string, PackageVersionInfo, PackageVersionInfo][]; - - toInstall.forEach(([name, target, installed]) => { - logger.info( - `Updating package.json with dependency ${name} ` + - `@ ${JSON.stringify(target.version)} (was ${JSON.stringify(installed.version)})...`, - ); - - if (packageJson.dependencies && packageJson.dependencies[name]) { - updateDependency(packageJson.dependencies, name, target.version); - - if (packageJson.devDependencies && packageJson.devDependencies[name]) { - delete packageJson.devDependencies[name]; - } - if (packageJson.peerDependencies && packageJson.peerDependencies[name]) { - delete packageJson.peerDependencies[name]; - } - } else if (packageJson.devDependencies && packageJson.devDependencies[name]) { - updateDependency(packageJson.devDependencies, name, target.version); - - if (packageJson.peerDependencies && packageJson.peerDependencies[name]) { - delete packageJson.peerDependencies[name]; - } - } else if (packageJson.peerDependencies && packageJson.peerDependencies[name]) { - updateDependency(packageJson.peerDependencies, name, target.version); - } else { - logger.warn(`Package ${name} was not found in dependencies.`); - } - }); - const eofMatches = packageJsonContent.match(/\r?\n$/); - const eof = eofMatches?.[0] ?? ''; - const newContent = JSON.stringify(packageJson, null, 2) + eof; - if (packageJsonContent != newContent || migrateOnly) { - if (!migrateOnly) { - tree.overwrite('/package.json', newContent); - } - - const externalMigrations: {}[] = []; - - // Run the migrate schematics with the list of packages to use. The collection contains - // version information and we need to do this post installation. Please note that the - // migration COULD fail and leave side effects on disk. - // Run the schematics task of those packages. - toInstall.forEach(([name, target, installed]) => { - if (!target.updateMetadata.migrations) { - return; - } - - externalMigrations.push({ - package: name, - collection: target.updateMetadata.migrations, - from: installed.version, - to: target.version, - }); - - return; - }); - - if (externalMigrations.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).externalMigrations = externalMigrations; - } - } -} - -function _getUpdateMetadata( - packageJson: PackageManifest, - logger: logging.LoggerApi, -): UpdateMetadata { - const metadata = packageJson['ng-update']; - - const result: UpdateMetadata = { - packageGroup: {}, - requirements: {}, - }; - - if (!metadata || typeof metadata != 'object' || Array.isArray(metadata)) { - return result; - } - - if (metadata['packageGroup']) { - const packageGroup = metadata['packageGroup']; - // Verify that packageGroup is an array of strings or an map of versions. This is not an error - // but we still warn the user and ignore the packageGroup keys. - if (Array.isArray(packageGroup) && packageGroup.every((x) => typeof x == 'string')) { - result.packageGroup = packageGroup.reduce((group, name) => { - group[name] = packageJson.version; - - return group; - }, result.packageGroup); - } else if ( - typeof packageGroup == 'object' && - packageGroup && - !Array.isArray(packageGroup) && - Object.values(packageGroup).every((x) => typeof x == 'string') - ) { - result.packageGroup = packageGroup; - } else { - logger.warn(`packageGroup metadata of package ${packageJson.name} is malformed. Ignoring.`); - } - - result.packageGroupName = Object.keys(result.packageGroup)[0]; - } - - if (typeof metadata['packageGroupName'] == 'string') { - result.packageGroupName = metadata['packageGroupName']; - } - - if (metadata['migrations']) { - const migrations = metadata['migrations']; - if (typeof migrations != 'string') { - logger.warn(`migrations metadata of package ${packageJson.name} is malformed. Ignoring.`); - } else { - result.migrations = migrations; - } - } - - return result; -} - -function _usageMessage( - options: UpdateSchema, - infoMap: Map, - logger: logging.LoggerApi, -) { - const packageGroups = new Map(); - const packagesToUpdate = [...infoMap.entries()] - .map(([name, info]) => { - const distTags = info.npmPackageJson['dist-tags'] ?? {}; - let tag = options.next ? (distTags['next'] ? 'next' : 'latest') : 'latest'; - let version = distTags[tag] ?? info.installed.version; - const versions = info.npmPackageJson.versions ?? {}; - let target = 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(versions) - .filter((v) => v.startsWith(nextMajorVersion)) - .sort((a, b) => (a > b ? -1 : 1)); - - if (nextMajorVersions.length) { - version = nextMajorVersions[0]; - target = versions[version]; - tag = ''; - } - } - } - - return { - name, - info, - version, - tag, - target, - }; - }) - .filter( - ({ info, version, target }) => - target?.['ng-update'] && semver.compare(info.installed.version, version) < 0, - ) - .map(({ name, info, version, tag, target }) => { - // Look for packageGroup. - const ngUpdate = target['ng-update']; - const packageGroup = ngUpdate?.['packageGroup']; - if (packageGroup) { - const packageGroupNames = Array.isArray(packageGroup) - ? packageGroup - : Object.keys(packageGroup); - const packageGroupName = - ngUpdate?.['packageGroupName'] || packageGroupNames.find((n) => infoMap.has(n)); - - if (packageGroupName) { - if (packageGroups.has(name)) { - return null; - } - - for (const groupName of packageGroupNames) { - packageGroups.set(groupName, packageGroupName); - } - - packageGroups.set(packageGroupName, packageGroupName); - name = packageGroupName; - } - } - - let command = `ng update ${name}`; - if (!tag) { - command += `@${semver.parse(version)?.major || version}`; - } else if (tag == 'next') { - command += ' --next'; - } - - return [name, `${info.installed.version} -> ${version} `, command]; - }) - .filter((x) => x !== null) - .sort((a, b) => (a && b ? a[0].localeCompare(b[0]) : 0)); - - if (packagesToUpdate.length == 0) { - logger.info('We analyzed your package.json and everything seems to be in order. Good work!'); - - return; - } - - logger.info('We analyzed your package.json, there are some packages to update:\n'); - - // Find the largest name to know the padding needed. - let namePad = Math.max(...[...infoMap.keys()].map((x) => x.length)) + 2; - if (!Number.isFinite(namePad)) { - namePad = 30; - } - const pads = [namePad, 25, 0]; - - logger.info( - ' ' + ['Name', 'Version', 'Command to update'].map((x, i) => x.padEnd(pads[i])).join(''), - ); - - const totalWidth = pads.reduce((sum, width) => sum + width, 20); - logger.info(` ${'-'.repeat(totalWidth)}`); - - packagesToUpdate.forEach((fields) => { - if (!fields) { - return; - } - - logger.info(' ' + fields.map((x, i) => x.padEnd(pads[i])).join('')); - }); - - logger.info( - `\nThere might be additional packages which don't provide 'ng update' capabilities that are outdated.\n` + - `You can update the additional packages by running the update command of your package manager.`, - ); - - return; -} - -/** - * Resolves a semver range or npm dist-tag to a specific version based on the package's registry metadata. - * It prioritizes non-deprecated versions and handles fallback to deprecated versions if necessary. - * - * @private - */ -function resolvePackageVersion( - metadata: NpmRepositoryPackageJson, - range: string, - next = false, -): string | null { - // Check if range matches an npm dist-tag directly (e.g. "latest", "next") - const distTags = metadata['dist-tags'] ?? {}; - if (distTags[range]) { - return distTags[range]; - } - // If 'next' is requested (e.g. via the --next CLI flag) but the package doesn't publish - // a 'next' pre-release tag, fallback to 'latest'. - if (range === 'next') { - return distTags['latest'] ?? null; - } - - // Split deprecated and non-deprecated versions from registry metadata - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - for (const [v, { deprecated }] of Object.entries(metadata.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } - - // Find the highest satisfying version, prioritizing non-deprecated versions - return ( - semver.maxSatisfying(packageVersionsNonDeprecated, range, { - includePrerelease: next || undefined, - }) ?? - semver.maxSatisfying(packageVersionsDeprecated, range, { - includePrerelease: next || undefined, - }) - ); -} - -/** - * Checks if Yarn Plug'n'Play is active in the current workspace. - * - * @private - */ -function isPnpActive(workspaceRoot: string): boolean { - return ( - process.versions.pnp !== undefined || - existsSync(path.join(workspaceRoot, '.pnp.cjs')) || - existsSync(path.join(workspaceRoot, '.pnp.js')) - ); -} - -/** - * Resolves and reads the installed package.json manifest for a package. - * It checks the virtual schematic Tree first (vital for unit tests/mocks), - * and falls back to physical disk resolution using createRequire only if Yarn PnP is active. - * - * @private - */ -function getInstalledPackageJson( - tree: Tree, - packageName: string, - workspaceRoot: string, -): PackageManifest | null { - // First, check the virtual tree (critical for testing mocks) - const pkgJsonPath = `/node_modules/${packageName}/package.json`; - if (tree.exists(pkgJsonPath)) { - try { - return tree.readJson(pkgJsonPath) as PackageManifest; - } catch {} - } - - // In Yarn PnP, mock package trees are not written to node_modules in the virtual tree, - // so we resolve the manifest physically from Yarn's zip cache via createRequire. - // Note: This fallback resolution is strictly gated on Yarn PnP being active. Because schematics - // operate on a virtual file system (Tree), running disk lookups in non-PnP - // environments could cause tests to resolve dependencies from this monorepo's own node_modules - // instead of the simulated virtual file system. - if (isPnpActive(workspaceRoot)) { - try { - const workspaceRequire = createRequire(path.join(workspaceRoot, 'package.json')); - const manifestPath = workspaceRequire.resolve(`${packageName}/package.json`); - const content = readFileSync(manifestPath, 'utf8'); - - return JSON.parse(content) as PackageManifest; - } catch {} - } - - return null; -} - -function getInstalledVersion( - tree: Tree, - packageName: string, - workspaceRoot: string, -): string | null { - const pkgJson = getInstalledPackageJson(tree, packageName, workspaceRoot); - - return pkgJson?.version ?? null; -} - -function _buildLocalPackageInfo( - tree: Tree, - name: string, - allDependencies: ReadonlyMap, - workspaceRoot: string, - logger: logging.LoggerApi, -): PackageInfo { - const packageJsonRange = allDependencies.get(name); - if (!packageJsonRange) { - throw new SchematicsException(`Package ${JSON.stringify(name)} was not found in package.json.`); - } - - const localPkgJson = getInstalledPackageJson(tree, name, workspaceRoot); - if (!localPkgJson) { - throw new SchematicsException(`Package ${name} is not installed.`); - } - - return { - name, - npmPackageJson: {} as NpmRepositoryPackageJson, - installed: { - version: localPkgJson.version as VersionRange, - packageJson: localPkgJson, - updateMetadata: _getUpdateMetadata(localPkgJson, logger), - }, - packageJsonRange, - }; -} - -function _buildPackageInfo( - tree: Tree, - packages: Map, - allDependencies: ReadonlyMap, - npmPackageJson: NpmRepositoryPackageJson, - workspaceRoot: string, - logger: logging.LoggerApi, -): PackageInfo { - const name = npmPackageJson.name; - const packageJsonRange = allDependencies.get(name); - if (!packageJsonRange) { - throw new SchematicsException(`Package ${JSON.stringify(name)} was not found in package.json.`); - } - - const localPkgJson = getInstalledPackageJson(tree, name, workspaceRoot); - let installedVersion = localPkgJson?.version; - - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - - for (const [version, { deprecated }] of Object.entries(npmPackageJson.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(version); - } else { - packageVersionsNonDeprecated.push(version); - } - } - - const findSatisfyingVersion = (targetVersion: VersionRange): VersionRange | undefined => - ((semver.maxSatisfying(packageVersionsNonDeprecated, targetVersion) ?? - semver.maxSatisfying(packageVersionsDeprecated, targetVersion)) as VersionRange | null) ?? - undefined; - - if (!installedVersion) { - // Find the version from NPM that fits the range to max. - installedVersion = findSatisfyingVersion(packageJsonRange); - } - - if (!installedVersion) { - throw new SchematicsException( - `An unexpected error happened; could not determine version for package ${name}.`, - ); - } - - const versions = npmPackageJson.versions ?? {}; - const installedPackageJson = versions[installedVersion] || localPkgJson; - if (!installedPackageJson) { - throw new SchematicsException( - `An unexpected error happened; package ${name} has no version ${installedVersion}.`, - ); - } - - let targetVersion: VersionRange | undefined = packages.get(name); - if (targetVersion) { - const distTags = npmPackageJson['dist-tags'] ?? {}; - if (distTags[targetVersion]) { - targetVersion = distTags[targetVersion] as VersionRange; - } else if (targetVersion == 'next') { - targetVersion = distTags['latest'] as VersionRange; - } else { - targetVersion = findSatisfyingVersion(targetVersion); - } - } - - if (targetVersion && semver.lte(targetVersion, installedVersion)) { - logger.debug(`Package ${name} already satisfied by package.json (${packageJsonRange}).`); - targetVersion = undefined; - } - - const target: PackageVersionInfo | undefined = targetVersion - ? { - version: targetVersion, - packageJson: versions[targetVersion], - updateMetadata: _getUpdateMetadata(versions[targetVersion], logger), - } - : undefined; - - return { - name, - npmPackageJson, - installed: { - version: installedVersion as VersionRange, - packageJson: installedPackageJson as PackageManifest, - updateMetadata: _getUpdateMetadata(installedPackageJson as PackageManifest, logger), - }, - target, - packageJsonRange, - }; -} - -function _buildPackageList( - options: UpdateSchema, - projectDeps: Map, - logger: logging.LoggerApi, -): Map { - // Parse the packages options to set the targeted version. - const packages = new Map(); - const commandLinePackages = - options.packages && options.packages.length > 0 ? options.packages : []; - - for (const pkg of commandLinePackages) { - // Split the version asked on command line. - const m = pkg.match(/^((?:@[^/]{1,100}\/)?[^@]{1,100})(?:@(.{1,100}))?$/); - if (!m) { - logger.warn(`Invalid package argument: ${JSON.stringify(pkg)}. Skipping.`); - continue; - } - - const [, npmName, maybeVersion] = m; - - const version = projectDeps.get(npmName); - if (!version) { - logger.warn(`Package not installed: ${JSON.stringify(npmName)}. Skipping.`); - continue; - } - - packages.set(npmName, (maybeVersion || (options.next ? 'next' : 'latest')) as VersionRange); - } - - return packages; -} - -function _addPackageGroup( - tree: Tree, - packages: Map, - allDependencies: ReadonlyMap, - npmPackageJson: NpmRepositoryPackageJson, - logger: logging.LoggerApi, -): void { - const maybePackage = packages.get(npmPackageJson.name); - if (!maybePackage) { - return; - } - - const distTags = npmPackageJson['dist-tags'] ?? {}; - let version = maybePackage; - if (distTags[version]) { - version = distTags[version] as VersionRange; - } else if (version === 'next') { - version = distTags['latest'] as VersionRange; - } else { - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - const versions = npmPackageJson.versions ?? {}; - for (const [v, { deprecated }] of Object.entries(versions)) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } - version = - ((semver.maxSatisfying(packageVersionsNonDeprecated, version) ?? - semver.maxSatisfying(packageVersionsDeprecated, version)) as VersionRange | null) ?? - version; - } - - const versions = npmPackageJson.versions ?? {}; - if (!versions[version]) { - return; - } - const ngUpdateMetadata = versions[version]['ng-update']; - if (!ngUpdateMetadata) { - return; - } - - const packageGroup = ngUpdateMetadata['packageGroup']; - if (!packageGroup) { - return; - } - let packageGroupNormalized: Record; - if (Array.isArray(packageGroup) && !packageGroup.some((x) => typeof x != 'string')) { - packageGroupNormalized = packageGroup.reduce( - (acc, curr) => { - acc[curr] = maybePackage; - - return acc; - }, - {} as { [name: string]: string }, - ); - } else if ( - typeof packageGroup == 'object' && - packageGroup && - !Array.isArray(packageGroup) && - Object.values(packageGroup).every((x) => typeof x == 'string') - ) { - packageGroupNormalized = packageGroup; - } else { - logger.warn(`packageGroup metadata of package ${npmPackageJson.name} is malformed. Ignoring.`); - - return; - } - - for (const [name, value] of Object.entries(packageGroupNormalized)) { - // Don't override names from the command line. - // Remove packages that aren't installed. - if (!packages.has(name) && allDependencies.has(name)) { - packages.set(name, value as VersionRange); - } - } -} - -/** - * Add peer dependencies of packages on the command line to the list of packages to update. - * We don't do verification of the versions here as this will be done by a later step (and can - * be ignored by the --force flag). - * @private - */ -async function _addPeerDependencies( - tree: Tree, - packages: Map, - allDependencies: ReadonlyMap, - npmPackageJson: NpmRepositoryPackageJson, - workspaceRoot: string, - fetchMetadata: (name: string) => Promise, - logger: logging.LoggerApi, -): Promise { - const maybePackage = packages.get(npmPackageJson.name); - if (!maybePackage) { - return; - } - const distTags = npmPackageJson['dist-tags'] ?? {}; - const version = distTags[maybePackage] || maybePackage; - const versions = npmPackageJson.versions ?? {}; - const packageJson = versions[version]; - if (!packageJson) { - return; - } - - for (const [peer, range] of Object.entries(packageJson.peerDependencies || {})) { - if (packages.has(peer)) { - continue; - } - - const installedVersion = getInstalledVersion(tree, peer, workspaceRoot); - if (installedVersion) { - if (semver.satisfies(installedVersion, range)) { - continue; - } - } else { - const packageJsonRange = allDependencies.get(peer); - if (packageJsonRange) { - const peerMetadata = await fetchMetadata(peer); - if (peerMetadata) { - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - for (const [v, { deprecated }] of Object.entries(peerMetadata.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } - const resolvedInstalledVersion = - semver.maxSatisfying(packageVersionsNonDeprecated, packageJsonRange) ?? - semver.maxSatisfying(packageVersionsDeprecated, packageJsonRange); - - if (resolvedInstalledVersion && semver.satisfies(resolvedInstalledVersion, range)) { - continue; - } - } - } - } - - packages.set(peer, range as VersionRange); - } -} - -function _getAllDependencies(tree: Tree): Array { - const { dependencies, devDependencies, peerDependencies } = tree.readJson( - '/package.json', - ) as PackageManifest; - - return [ - ...(Object.entries(peerDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(devDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(dependencies || {}) as Array<[string, VersionRange]>), - ]; -} - -function _formatVersion(version: string | undefined) { - if (version === undefined) { - return undefined; - } - - if (!version.match(/^\d{1,30}\.\d{1,30}\.\d{1,30}/)) { - version += '.0'; - } - if (!version.match(/^\d{1,30}\.\d{1,30}\.\d{1,30}/)) { - version += '.0'; - } - if (!semver.valid(version)) { - throw new SchematicsException(`Invalid migration version: ${JSON.stringify(version)}`); - } - - return version; -} - -/** - * Returns whether or not the given package specifier (the value string in a - * `package.json` dependency) is hosted in the NPM registry. - * @throws When the specifier cannot be parsed. - */ -function isPkgFromRegistry(name: string, specifier: string): boolean { - const result = npa.resolve(name, specifier); - - return !!result.registry; -} - -export default function (options: UpdateSchema): Rule { - if (!options.packages) { - // We cannot just return this because we need to fetch the packages from NPM still for the - // help/guide to show. - options.packages = []; - } else { - // We split every packages by commas to allow people to pass in multiple and make it an array. - options.packages = options.packages.reduce((acc, curr) => { - return acc.concat(curr.split(',')); - }, [] as string[]); - } - - if (options.migrateOnly && options.from) { - if (options.packages.length !== 1) { - throw new SchematicsException('--from requires that only a single package be passed.'); - } - } - - options.from = _formatVersion(options.from); - options.to = _formatVersion(options.to); - const usingYarn = options.packageManager === 'yarn'; - - return async (tree: Tree, context: SchematicContext) => { - const logger = context.logger; - const npmDeps = new Map( - _getAllDependencies(tree).filter(([name, specifier]) => { - try { - return isPkgFromRegistry(name, specifier); - } catch { - logger.warn(`Package ${name} was not found on the registry. Skipping.`); - - return false; - } - }), - ); - const packages = _buildPackageList(options, npmDeps, logger); - - const workspaceRoot = options.workspaceRoot ?? process.cwd(); - const npmPackageJsonMap = new Map(); - - const getOrFetchPackageMetadata = async ( - packageName: string, - ): Promise => { - let metadata = npmPackageJsonMap.get(packageName); - if (!metadata) { - const raw = await getNpmPackageJson(packageName, logger, { - registry: options.registry, - usingYarn, - verbose: options.verbose, - }); - if (raw.name) { - metadata = raw as NpmRepositoryPackageJson; - npmPackageJsonMap.set(packageName, metadata); - } - } - - return metadata ?? null; - }; - - if (packages.size === 0) { - // User ran just `ng update` to see the outdated package list. - // We must fetch metadata for all npm dependencies to generate the usage message. - await Promise.all( - Array.from(npmDeps.keys()).map(async (depName) => { - await getOrFetchPackageMetadata(depName); - }), - ); - } else { - // User requested updates. We resolve dependencies lazily. - let lastPackagesSize; - do { - lastPackagesSize = packages.size; - - let lastGroupSize; - do { - lastGroupSize = packages.size; - for (const name of Array.from(packages.keys())) { - const metadata = await getOrFetchPackageMetadata(name); - const spec = packages.get(name); - if (metadata && spec) { - const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); - if (resolvedVersion) { - packages.set(name, resolvedVersion as VersionRange); - } - _addPackageGroup(tree, packages, npmDeps, metadata, logger); - } - } - } while (packages.size > lastGroupSize); - - for (const name of Array.from(packages.keys())) { - const metadata = await getOrFetchPackageMetadata(name); - const spec = packages.get(name); - if (metadata && spec) { - const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); - if (resolvedVersion) { - packages.set(name, resolvedVersion as VersionRange); - } - await _addPeerDependencies( - tree, - packages, - npmDeps, - metadata, - workspaceRoot, - getOrFetchPackageMetadata, - logger, - ); - } - } - } while (packages.size > lastPackagesSize); - } - - // Build the PackageInfo for each module. - const packageInfoMap = new Map(); - for (const depName of npmDeps.keys()) { - const isUpdating = packages.has(depName); - const localPkgJson = getInstalledPackageJson(tree, depName, workspaceRoot); - - if (isUpdating || !localPkgJson) { - // If updating OR not installed locally, resolve via registry metadata - const metadata = await getOrFetchPackageMetadata(depName); - if (metadata) { - packageInfoMap.set( - depName, - _buildPackageInfo(tree, packages, npmDeps, metadata, workspaceRoot, logger), - ); - } else { - // Fallback if metadata could not be fetched - packageInfoMap.set( - depName, - _buildLocalPackageInfo(tree, depName, npmDeps, workspaceRoot, logger), - ); - } - } else { - // If not updating and installed locally, resolve purely locally - packageInfoMap.set( - depName, - _buildLocalPackageInfo(tree, depName, npmDeps, workspaceRoot, logger), - ); - } - } - - // Now that we have all the information, check the flags. - if (packages.size > 0) { - if (options.migrateOnly && options.from && options.packages) { - return; - } - - const sublog = new logging.LevelCapLogger('validation', logger.createChild(''), 'warn'); - _validateUpdatePackages(packageInfoMap, !!options.force, !!options.next, sublog); - - _performUpdate(tree, context, packageInfoMap, logger, !!options.migrateOnly); - } else { - _usageMessage(options, packageInfoMap, logger); - } - }; -} diff --git a/packages/angular/cli/src/commands/update/schematic/index_spec.ts b/packages/angular/cli/src/commands/update/schematic/index_spec.ts deleted file mode 100644 index 7e8ca436150d..000000000000 --- a/packages/angular/cli/src/commands/update/schematic/index_spec.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * @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 { normalize, virtualFs } from '@angular-devkit/core'; -import { HostTree } from '@angular-devkit/schematics'; -import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; -import * as semver from 'semver'; -import { angularMajorCompatGuarantee } from './index'; - -describe('angularMajorCompatGuarantee', () => { - [ - '5.0.0', - '5.1.0', - '5.20.0', - '6.0.0', - '6.0.0-rc.0', - '6.0.0-beta.0', - '6.1.0-beta.0', - '6.1.0-rc.0', - '6.10.11', - ].forEach((golden) => { - it('works with ' + JSON.stringify(golden), () => { - expect(semver.satisfies(golden, angularMajorCompatGuarantee('^5.0.0'))).toBeTruthy(); - }); - }); -}); - -describe('@schematics/update', () => { - const schematicRunner = new SchematicTestRunner( - '@schematics/update', - require.resolve('./collection.json'), - ); - let host: virtualFs.test.TestHost; - let appTree: UnitTestTree = new UnitTestTree(new HostTree()); - - beforeEach(() => { - host = new virtualFs.test.TestHost({ - '/package.json': `{ - "name": "blah", - "dependencies": { - "@angular-devkit-tests/update-base": "1.0.0" - } - }`, - }); - appTree = new UnitTestTree(new HostTree(host)); - }); - - it('ignores dependencies not hosted on the NPM registry', async () => { - let newTree = new UnitTestTree( - new HostTree( - new virtualFs.test.TestHost({ - '/package.json': `{ - "name": "blah", - "dependencies": { - "@angular-devkit-tests/update-base": "file:update-base-1.0.0.tgz" - } - }`, - }), - ), - ); - - newTree = await schematicRunner.runSchematic('update', undefined, newTree); - const packageJson = JSON.parse(newTree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular-devkit-tests/update-base']).toBe( - 'file:update-base-1.0.0.tgz', - ); - }, 45000); - - it('should not error with yarn 2.0 protocols', async () => { - let newTree = new UnitTestTree( - new HostTree( - new virtualFs.test.TestHost({ - '/package.json': `{ - "name": "blah", - "dependencies": { - "src": "src@link:./src", - "@angular-devkit-tests/update-base": "1.0.0" - } - }`, - }), - ), - ); - - newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit-tests/update-base'], - }, - newTree, - ); - const { dependencies } = JSON.parse(newTree.readContent('/package.json')); - expect(dependencies['@angular-devkit-tests/update-base']).toBe('1.1.0'); - }); - - it('updates Angular as compatible with Angular N-1', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - const dependencies = packageJson['dependencies']; - dependencies['@angular-devkit-tests/update-peer-dependencies-angular-5'] = '1.0.0'; - dependencies['@angular/core'] = '5.1.0'; - dependencies['rxjs'] = '5.5.0'; - dependencies['zone.js'] = '0.8.26'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular/core@^6.0.0'], - }, - appTree, - ); - const newPpackageJson = JSON.parse(newTree.readContent('/package.json')); - expect(newPpackageJson['dependencies']['@angular/core'][0]).toBe('6'); - }, 45000); - - it('updates Angular as compatible with Angular N-1 (2)', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - const dependencies = packageJson['dependencies']; - dependencies['@angular-devkit-tests/update-peer-dependencies-angular-5-2'] = '1.0.0'; - dependencies['@angular/core'] = '5.1.0'; - dependencies['@angular/animations'] = '5.1.0'; - dependencies['@angular/common'] = '5.1.0'; - dependencies['@angular/compiler'] = '5.1.0'; - dependencies['@angular/compiler-cli'] = '5.1.0'; - dependencies['@angular/platform-browser'] = '5.1.0'; - dependencies['rxjs'] = '5.5.0'; - dependencies['zone.js'] = '0.8.26'; - dependencies['typescript'] = '2.4.2'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular/core@^6.0.0'], - }, - appTree, - ); - - const newPackageJson = JSON.parse(newTree.readContent('/package.json')); - expect(newPackageJson['dependencies']['@angular/core'][0]).toBe('6'); - expect(newPackageJson['dependencies']['rxjs'][0]).toBe('6'); - expect(newPackageJson['dependencies']['typescript'][0]).toBe('2'); - expect(newPackageJson['dependencies']['typescript'][2]).not.toBe('4'); - }, 45000); - - it('uses packageGroup for versioning', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - const dependencies = packageJson['dependencies']; - dependencies['@angular-devkit-tests/update-package-group-1'] = '1.0.0'; - dependencies['@angular-devkit-tests/update-package-group-2'] = '1.0.0'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit-tests/update-package-group-1'], - }, - appTree, - ); - const { dependencies: deps } = JSON.parse(newTree.readContent('/package.json')); - expect(deps['@angular-devkit-tests/update-package-group-1']).toBe('1.2.0'); - expect(deps['@angular-devkit-tests/update-package-group-2']).toBe('2.0.0'); - }, 45000); - - it('can migrate only', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - packageJson['dependencies']['@angular-devkit-tests/update-migrations'] = '1.0.0'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - }, - appTree, - ); - - const newPackageJson = JSON.parse(newTree.readContent('/package.json')); - expect(newPackageJson['dependencies']['@angular-devkit-tests/update-base']).toBe('1.0.0'); - expect(newPackageJson['dependencies']['@angular-devkit-tests/update-migrations']).toBe('1.0.0'); - }, 45000); - - it('can migrate from only', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - packageJson['dependencies']['@angular-devkit-tests/update-migrations'] = '1.6.0'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - from: '0.1.2', - }, - appTree, - ); - const { dependencies } = JSON.parse(newTree.readContent('/package.json')); - expect(dependencies['@angular-devkit-tests/update-migrations']).toBe('1.6.0'); - }, 45000); - - it('can install and migrate with --from (short version number)', async () => { - // Add the basic migration package. - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - packageJson['dependencies']['@angular-devkit-tests/update-migrations'] = '1.6.0'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const newTree = await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - from: '0', - }, - appTree, - ); - const { dependencies } = JSON.parse(newTree.readContent('/package.json')); - expect(dependencies['@angular-devkit-tests/update-migrations']).toBe('1.6.0'); - }, 45000); - - it('validates peer dependencies', async () => { - const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); - const packageJson = JSON.parse(content); - const dependencies = packageJson['dependencies']; - // TODO: when we start using a local npm registry for test packages, add a package that includes - // a optional peer dependency and a non-optional one for this test. Use it instead of - // @angular-devkit/build-angular, whose optional peerdep is @angular/localize and non-optional - // are typescript and @angular/compiler-cli. - dependencies['@angular-devkit/build-angular'] = '0.900.0-next.1'; - host.sync.write( - normalize('/package.json'), - virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), - ); - - const messages: string[] = []; - schematicRunner.logger.subscribe((x) => messages.push(x.message)); - const hasPeerdepMsg = (dep: string) => - messages.some((str) => str.includes(`missing peer dependency of "${dep}"`)); - - await schematicRunner.runSchematic( - 'update', - { - packages: ['@angular-devkit/build-angular'], - next: true, - }, - appTree, - ); - expect(hasPeerdepMsg('@angular/compiler-cli')).toBeTruthy(); - expect(hasPeerdepMsg('typescript')).toBeTruthy(); - expect(hasPeerdepMsg('@angular/localize')).toBeFalsy(); - }, 45000); - - it('does not remove newline at the end of package.json', async () => { - const newlineStyles = ['\n', '\r\n']; - for (const newline of newlineStyles) { - const packageJsonContent = `{ - "name": "blah", - "dependencies": { - "@angular-devkit-tests/update-base": "1.0.0" - } - }${newline}`; - const inputTree = new UnitTestTree( - new HostTree( - new virtualFs.test.TestHost({ - '/package.json': packageJsonContent, - }), - ), - ); - - const resultTree = await schematicRunner.runSchematic( - 'update', - { packages: ['@angular-devkit-tests/update-base'] }, - inputTree, - ); - - const resultTreeContent = resultTree.readContent('/package.json'); - expect(resultTreeContent.endsWith(newline)).toBeTrue(); - } - }); - - it('does not add a newline at the end of package.json', async () => { - const packageJsonContent = `{ - "name": "blah", - "dependencies": { - "@angular-devkit-tests/update-base": "1.0.0" - } - }`; - const inputTree = new UnitTestTree( - new HostTree( - new virtualFs.test.TestHost({ - '/package.json': packageJsonContent, - }), - ), - ); - - const resultTree = await schematicRunner.runSchematic( - 'update', - { packages: ['@angular-devkit-tests/update-base'] }, - inputTree, - ); - - const resultTreeContent = resultTree.readContent('/package.json'); - expect(resultTreeContent.endsWith('}')).toBeTrue(); - }); - - it('updates group members to the same version as the targeted package', async () => { - const packageJsonContent = `{ - "name": "test", - "dependencies": { - "@angular/cdk": "^19.2.19", - "@angular/common": "^19.2.0", - "@angular/compiler": "^19.2.0", - "@angular/core": "^19.2.0", - "@angular/forms": "^19.2.0", - "@angular/platform-browser": "^19.2.0", - "@angular/platform-browser-dynamic": "^19.2.0", - "@angular/router": "^19.2.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular-devkit/build-angular": "^19.2.21", - "@angular/cli": "^19.2.21", - "@angular/compiler-cli": "^19.2.0", - "typescript": "~5.7.2" - } - }`; - - const inputTree = new UnitTestTree( - new HostTree( - new virtualFs.test.TestHost({ - '/package.json': packageJsonContent, - }), - ), - ); - - const resultTree = await schematicRunner.runSchematic( - 'update', - { force: true, packages: ['@angular/cli@20', '@angular/cdk@20', '@angular/core@20'] }, - inputTree, - ); - - const { devDependencies, dependencies } = resultTree.readJson('/package.json') as { - devDependencies: Record; - dependencies: Record; - }; - - const version20Regexp = /^\^20.\d+.\d+$/; - - expect(devDependencies['typescript']).toMatch(/5\.9\.\d+/); - expect(devDependencies['@angular/cli']).toMatch(version20Regexp); - expect(devDependencies['@angular/compiler-cli']).toMatch(version20Regexp); - expect(dependencies['@angular/cdk']).toMatch(version20Regexp); - expect(dependencies['@angular/common']).toMatch(version20Regexp); - expect(dependencies['@angular/core']).toMatch(version20Regexp); - }, 45000); -}); diff --git a/packages/angular/cli/src/commands/update/schematic/schema.json b/packages/angular/cli/src/commands/update/schematic/schema.json deleted file mode 100644 index 63bf2df87813..000000000000 --- a/packages/angular/cli/src/commands/update/schematic/schema.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "SchematicsUpdateSchema", - "title": "Schematic Options Schema", - "type": "object", - "properties": { - "packages": { - "description": "The package or packages to update.", - "type": "array", - "items": { - "type": "string" - }, - "$default": { - "$source": "argv" - } - }, - "force": { - "description": "When false (the default), reports an error if installed packages are incompatible with the update.", - "default": false, - "type": "boolean" - }, - "next": { - "description": "Update to the latest version, including beta and RCs.", - "default": false, - "type": "boolean" - }, - "migrateOnly": { - "description": "Perform a migration, but do not update the installed version.", - "default": false, - "type": "boolean" - }, - "from": { - "description": "When using `--migrateOnly` for a single package, the version of that package from which to migrate.", - "type": "string" - }, - "to": { - "description": "When using `--migrateOnly` for a single package, the version of that package to which to migrate.", - "type": "string" - }, - "registry": { - "description": "The npm registry to use.", - "type": "string", - "oneOf": [ - { - "format": "uri" - }, - { - "format": "hostname" - } - ] - }, - "verbose": { - "description": "Display additional details during the update process.", - "type": "boolean" - }, - "packageManager": { - "description": "The preferred package manager configuration files to use for registry settings.", - "type": "string", - "default": "npm", - "enum": ["npm", "yarn", "pnpm", "bun"] - }, - "workspaceRoot": { - "description": "The path to the workspace root directory.", - "type": "string" - } - }, - "required": [] -} diff --git a/packages/angular/cli/src/commands/update/update-resolver.ts b/packages/angular/cli/src/commands/update/update-resolver.ts new file mode 100644 index 000000000000..7e840e13ae55 --- /dev/null +++ b/packages/angular/cli/src/commands/update/update-resolver.ts @@ -0,0 +1,1028 @@ +/** + * @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 { logging } from '@angular-devkit/core'; +import { existsSync, promises as fs, readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import npa from 'npm-package-arg'; +import * as semver from 'semver'; +import { + NpmRepositoryPackageJson, + PackageManifest, + getNpmPackageJson, +} from '../../utilities/package-metadata'; + +export type VersionRange = string & { __VERSION_RANGE: void }; +type PeerVersionTransform = string | ((range: string) => string); + +export function angularMajorCompatGuarantee(range: string) { + let newRange = semver.validRange(range); + if (!newRange) { + return range; + } + let major = 1; + while (!semver.gtr(major + '.0.0', newRange)) { + major++; + if (major >= 99) { + return newRange; + } + } + + newRange = range; + for (let minor = 0; minor < 20; minor++) { + newRange += ` || ^${major}.${minor}.0-alpha.0 `; + } + + return semver.validRange(newRange) || range; +} + +const knownPeerCompatibleList: { [name: string]: PeerVersionTransform } = { + '@angular/core': angularMajorCompatGuarantee, +}; + +export interface PackageVersionInfo { + version: VersionRange; + packageJson: PackageManifest; + updateMetadata: UpdateMetadata; +} + +export interface PackageInfo { + name: string; + npmPackageJson: NpmRepositoryPackageJson; + installed: PackageVersionInfo; + target?: PackageVersionInfo; + packageJsonRange: string; +} + +export interface UpdateMetadata { + packageGroupName?: string; + packageGroup: { [packageName: string]: string }; + requirements: { [packageName: string]: string }; + migrations?: string; +} + +export interface UpdateResolverOptions { + packages?: string[]; + force?: boolean; + next?: boolean; + migrateOnly?: boolean; + from?: string; + to?: string; + registry?: string; + packageManager?: string; + verbose?: boolean; + workspaceRoot?: string; +} + +export interface UpdatePlan { + packagesToUpdate: Map; // name -> target version range + migrationsToRun: { package: string; collection: string; from: string; to: string }[]; + packageInfoMap: Map; +} + +function _updatePeerVersion(infoMap: Map, name: string, range: string) { + const maybePackageInfo = infoMap.get(name); + if (!maybePackageInfo) { + return range; + } + if (maybePackageInfo.target) { + name = maybePackageInfo.target.updateMetadata.packageGroupName || name; + } else { + name = maybePackageInfo.installed.updateMetadata.packageGroupName || name; + } + + const maybeTransform = knownPeerCompatibleList[name]; + if (maybeTransform) { + if (typeof maybeTransform == 'function') { + return maybeTransform(range); + } else { + return maybeTransform; + } + } + + return range; +} + +function _validateForwardPeerDependencies( + name: string, + infoMap: Map, + logger: logging.LoggerApi, +): boolean { + let error = false; + const info = infoMap.get(name); + if (!info || !info.target) { + return error; + } + + const peerDependencies = info.target.packageJson.peerDependencies || {}; + const peerDependenciesMeta = info.target.packageJson.peerDependenciesMeta || {}; + + for (const [peer, range] of Object.entries(peerDependencies)) { + const peerInfo = infoMap.get(peer); + if (!peerInfo) { + continue; + } + + const isOptional = !!peerDependenciesMeta[peer]?.optional; + const resolvedRange = _updatePeerVersion(infoMap, peer, range); + const resolvedVersion = peerInfo.target ? peerInfo.target.version : peerInfo.installed.version; + + if (!semver.satisfies(resolvedVersion, resolvedRange, { includePrerelease: true })) { + logger.error( + `Package ${JSON.stringify(name)} has an incompatible peer dependency to ` + + `${JSON.stringify(peer)} (requires ${JSON.stringify(range)}, ` + + `would install ${JSON.stringify(resolvedVersion)}).`, + ); + error = error || !isOptional; + } + } + + return error; +} + +function _validateReversePeerDependencies( + name: string, + version: string, + infoMap: Map, + logger: logging.LoggerApi, + next: boolean, +): boolean { + let error = false; + for (const [installed, installedInfo] of infoMap.entries()) { + const installedLogger = logger.createChild(installed); + installedLogger.debug(`${installed}...`); + const peers = (installedInfo.target || installedInfo.installed).packageJson.peerDependencies; + const peersMeta = (installedInfo.target || installedInfo.installed).packageJson + .peerDependenciesMeta; + + for (const [peer, range] of Object.entries(peers || {})) { + if (peer !== name) { + continue; + } + + const isOptional = !!peersMeta?.[peer]?.optional; + const resolvedRange = _updatePeerVersion(infoMap, name, range); + if (!semver.satisfies(version, resolvedRange, { includePrerelease: next || undefined })) { + logger.error( + `Package ${JSON.stringify(installed)} has an incompatible peer dependency to ` + + `${JSON.stringify(name)} (requires ${JSON.stringify(range)}, ` + + `would install ${JSON.stringify(version)}).`, + ); + error = error || !isOptional; + } + } + } + + return error; +} + +function _validateUpdatePackages( + infoMap: Map, + force: boolean, + next: boolean, + logger: logging.LoggerApi, +): void { + logger.debug('Validating peer dependencies...'); + let error = false; + + for (const name of infoMap.keys()) { + const info = infoMap.get(name); + if (!info || !info.target) { + continue; + } + + logger.debug(`Checking ${name}...`); + error = _validateForwardPeerDependencies(name, infoMap, logger) || error; + error = + _validateReversePeerDependencies(name, info.target.version, infoMap, logger, next) || error; + } + + if (error && !force) { + throw new Error( + 'Incompatible peer dependencies found. See above for details. ' + + 'You can bypass this check using the --force option.', + ); + } +} + +function _getUpdateMetadata( + packageJson: PackageManifest, + logger: logging.LoggerApi, +): UpdateMetadata { + const metadata = packageJson['ng-update']; + + const result: UpdateMetadata = { + packageGroup: {}, + requirements: {}, + }; + + if (!metadata || typeof metadata != 'object' || Array.isArray(metadata)) { + return result; + } + + if (metadata['packageGroup']) { + const packageGroup = metadata['packageGroup']; + if (Array.isArray(packageGroup) && packageGroup.every((x) => typeof x == 'string')) { + result.packageGroup = packageGroup.reduce( + (group, name) => { + group[name] = packageJson.version; + + return group; + }, + {} as { [key: string]: string }, + ); + } else if (typeof packageGroup == 'object' && packageGroup !== null) { + result.packageGroup = Object.entries(packageGroup).reduce( + (group, [name, version]) => { + if (typeof version == 'string') { + group[name] = version; + } + + return group; + }, + {} as { [key: string]: string }, + ); + } else { + logger.warn(`PackageGroup metadata for ${packageJson.name} is malformed. Ignoring.`); + } + } + + if (typeof metadata['packageGroupName'] == 'string') { + result.packageGroupName = metadata['packageGroupName']; + } + + if (typeof metadata['migrations'] == 'string') { + result.migrations = metadata['migrations']; + } + + return result; +} + +export function isPnpActive(workspaceRoot: string): boolean { + return ( + process.versions.pnp !== undefined || + existsSync(path.join(workspaceRoot, '.pnp.cjs')) || + existsSync(path.join(workspaceRoot, '.pnp.js')) + ); +} + +export function findPackageJson(workspaceDir: string, packageName: string): string | undefined { + if (isPnpActive(workspaceDir)) { + try { + const workspaceRequire = createRequire(path.join(workspaceDir, 'package.json')); + + return workspaceRequire.resolve(`${packageName}/package.json`); + } catch { + return undefined; + } + } + + let currentDir = workspaceDir; + while (true) { + const candidatePath = path.join(currentDir, 'node_modules', packageName, 'package.json'); + if (existsSync(candidatePath)) { + return realpathSync(candidatePath); + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return undefined; +} + +function getInstalledPackageJson( + packageName: string, + workspaceRoot: string, +): PackageManifest | null { + try { + const manifestPath = findPackageJson(workspaceRoot, packageName); + if (manifestPath) { + const content = readFileSync(manifestPath, 'utf8'); + + return JSON.parse(content) as PackageManifest; + } + } catch {} + + return null; +} + +function getInstalledVersion(packageName: string, workspaceRoot: string): string | null { + const pkgJson = getInstalledPackageJson(packageName, workspaceRoot); + + return pkgJson?.version ?? null; +} + +function _buildLocalPackageInfo( + name: string, + allDependencies: ReadonlyMap, + workspaceRoot: string, +): PackageInfo { + const packageJsonRange = allDependencies.get(name); + if (!packageJsonRange) { + throw new Error(`Package ${JSON.stringify(name)} was not found in package.json.`); + } + + const localPkgJson = getInstalledPackageJson(name, workspaceRoot); + if (!localPkgJson) { + throw new Error(`Package ${name} is not installed.`); + } + + const installedVersion = localPkgJson.version; + const npmPackageJson: NpmRepositoryPackageJson = { + name, + versions: { + [installedVersion]: localPkgJson, + }, + 'dist-tags': {}, + } as unknown as NpmRepositoryPackageJson; + + const logger = new logging.NullLogger(); + + return { + name, + npmPackageJson, + installed: { + version: installedVersion as VersionRange, + packageJson: localPkgJson, + updateMetadata: _getUpdateMetadata(localPkgJson, logger), + }, + packageJsonRange, + }; +} + +function _buildPackageInfo( + packages: Map, + allDependencies: ReadonlyMap, + npmPackageJson: NpmRepositoryPackageJson, + workspaceRoot: string, + logger: logging.LoggerApi, +): PackageInfo { + const name = npmPackageJson.name; + const packageJsonRange = allDependencies.get(name); + if (!packageJsonRange) { + throw new Error(`Package ${JSON.stringify(name)} was not found in package.json.`); + } + + const localPkgJson = getInstalledPackageJson(name, workspaceRoot); + let installedVersion = localPkgJson?.version; + + const packageVersionsNonDeprecated: string[] = []; + const packageVersionsDeprecated: string[] = []; + + for (const [version, { deprecated }] of Object.entries(npmPackageJson.versions ?? {})) { + if (deprecated) { + packageVersionsDeprecated.push(version); + } else { + packageVersionsNonDeprecated.push(version); + } + } + + const findSatisfyingVersion = (targetVersion: VersionRange): VersionRange | undefined => + ((semver.maxSatisfying(packageVersionsNonDeprecated, targetVersion) ?? + semver.maxSatisfying(packageVersionsDeprecated, targetVersion)) as VersionRange | null) ?? + undefined; + + if (!installedVersion) { + installedVersion = findSatisfyingVersion(packageJsonRange); + } + + if (!installedVersion) { + throw new Error( + `An unexpected error happened; could not determine version for package ${name}.`, + ); + } + + const versions = npmPackageJson.versions ?? {}; + const installedPackageJson = versions[installedVersion] || localPkgJson; + if (!installedPackageJson) { + throw new Error( + `An unexpected error happened; package ${name} has no version ${installedVersion}.`, + ); + } + + let targetVersion: VersionRange | undefined = packages.get(name); + if (targetVersion) { + const distTags = npmPackageJson['dist-tags'] ?? {}; + if (distTags[targetVersion]) { + targetVersion = distTags[targetVersion] as VersionRange; + } else if (targetVersion == 'next') { + targetVersion = distTags['latest'] as VersionRange; + } else { + targetVersion = findSatisfyingVersion(targetVersion); + } + } + + if (targetVersion && semver.lte(targetVersion, installedVersion)) { + logger.debug(`Package ${name} already satisfied by package.json (${packageJsonRange}).`); + targetVersion = undefined; + } + + const target: PackageVersionInfo | undefined = targetVersion + ? { + version: targetVersion, + packageJson: versions[targetVersion], + updateMetadata: _getUpdateMetadata(versions[targetVersion], logger), + } + : undefined; + + return { + name, + npmPackageJson, + installed: { + version: installedVersion as VersionRange, + packageJson: installedPackageJson, + updateMetadata: _getUpdateMetadata(installedPackageJson, logger), + }, + target, + packageJsonRange, + }; +} + +function _buildPackageList( + options: UpdateResolverOptions, + allDependencies: ReadonlyMap, + logger: logging.LoggerApi, +): Map { + const packages = new Map(); + const inputPackages = options.packages ?? []; + + if (inputPackages.length === 0) { + return packages; + } + + for (const pkg of inputPackages) { + let pkgName = pkg; + let pkgVersion: string | undefined; + + if (pkg.startsWith('@')) { + const parts = pkg.split('@'); + pkgName = '@' + parts[1]; + pkgVersion = parts[2]; + } else if (pkg.includes('@')) { + const parts = pkg.split('@'); + pkgName = parts[0]; + pkgVersion = parts[1]; + } + + if (!allDependencies.has(pkgName)) { + throw new Error(`Package ${JSON.stringify(pkgName)} is not in package.json.`); + } + + if (options.migrateOnly && !pkgVersion && options.from) { + pkgVersion = options.from; + } + + packages.set(pkgName, (pkgVersion || (options.next ? 'next' : 'latest')) as VersionRange); + } + + return packages; +} + +function resolvePackageVersion( + metadata: NpmRepositoryPackageJson, + range: string, + next = false, +): string | null { + const distTags = metadata['dist-tags'] ?? {}; + if (distTags[range]) { + return distTags[range]; + } + if (range === 'next') { + return distTags['latest'] ?? null; + } + + const packageVersionsNonDeprecated: string[] = []; + const packageVersionsDeprecated: string[] = []; + for (const [v, { deprecated }] of Object.entries(metadata.versions ?? {})) { + if (deprecated) { + packageVersionsDeprecated.push(v); + } else { + packageVersionsNonDeprecated.push(v); + } + } + + return ( + semver.maxSatisfying(packageVersionsNonDeprecated, range, { + includePrerelease: next || undefined, + }) ?? + semver.maxSatisfying(packageVersionsDeprecated, range, { + includePrerelease: next || undefined, + }) + ); +} + +function _addPackageGroup( + packages: Map, + allDependencies: ReadonlyMap, + metadata: NpmRepositoryPackageJson, + logger: logging.LoggerApi, +): void { + const maybePackage = packages.get(metadata.name); + if (!maybePackage) { + return; + } + + const distTags = metadata['dist-tags'] ?? {}; + let version = maybePackage; + if (distTags[version]) { + version = distTags[version] as VersionRange; + } else if (version === 'next') { + version = distTags['latest'] as VersionRange; + } else { + const packageVersionsNonDeprecated: string[] = []; + const packageVersionsDeprecated: string[] = []; + const versions = metadata.versions ?? {}; + for (const [v, { deprecated }] of Object.entries(versions)) { + if (deprecated) { + packageVersionsDeprecated.push(v); + } else { + packageVersionsNonDeprecated.push(v); + } + } + version = + ((semver.maxSatisfying(packageVersionsNonDeprecated, version) ?? + semver.maxSatisfying(packageVersionsDeprecated, version)) as VersionRange | null) ?? + version; + } + + const versions = metadata.versions ?? {}; + if (!versions[version]) { + return; + } + const ngUpdateMetadata = versions[version]['ng-update']; + if (!ngUpdateMetadata) { + return; + } + + const packageGroup = ngUpdateMetadata['packageGroup']; + if (!packageGroup) { + return; + } + let packageGroupNormalized: Record; + if (Array.isArray(packageGroup) && !packageGroup.some((x) => typeof x != 'string')) { + packageGroupNormalized = packageGroup.reduce( + (acc, curr) => { + acc[curr] = version; + + return acc; + }, + {} as Record, + ); + } else if (typeof packageGroup === 'object' && packageGroup !== null) { + packageGroupNormalized = Object.entries(packageGroup).reduce( + (acc, [name, v]) => { + if (typeof v === 'string') { + acc[name] = v; + } + + return acc; + }, + {} as Record, + ); + } else { + logger.warn(`PackageGroup metadata for ${metadata.name} is malformed. Ignoring.`); + + return; + } + + for (const [member, memberVersion] of Object.entries(packageGroupNormalized)) { + if (packages.has(member)) { + continue; + } + if (allDependencies.has(member)) { + packages.set(member, memberVersion as VersionRange); + } + } +} + +async function _addPeerDependencies( + packages: Map, + allDependencies: ReadonlyMap, + npmPackageJson: NpmRepositoryPackageJson, + workspaceRoot: string, + fetchMetadata: (name: string) => Promise, + logger: logging.LoggerApi, +): Promise { + const maybePackage = packages.get(npmPackageJson.name); + if (!maybePackage) { + return; + } + + const distTags = npmPackageJson['dist-tags'] ?? {}; + const version = distTags[maybePackage] || maybePackage; + const versions = npmPackageJson.versions ?? {}; + const packageJson = versions[version]; + if (!packageJson) { + return; + } + + for (const [peer, range] of Object.entries(packageJson.peerDependencies || {})) { + if (packages.has(peer)) { + continue; + } + + const installedVersion = getInstalledVersion(peer, workspaceRoot); + if (installedVersion) { + if (semver.satisfies(installedVersion, range)) { + continue; + } + } else { + const packageJsonRange = allDependencies.get(peer); + if (packageJsonRange) { + const peerMetadata = await fetchMetadata(peer); + if (peerMetadata) { + const packageVersionsNonDeprecated: string[] = []; + const packageVersionsDeprecated: string[] = []; + for (const [v, { deprecated }] of Object.entries(peerMetadata.versions ?? {})) { + if (deprecated) { + packageVersionsDeprecated.push(v); + } else { + packageVersionsNonDeprecated.push(v); + } + } + const resolvedInstalledVersion = + semver.maxSatisfying(packageVersionsNonDeprecated, packageJsonRange) ?? + semver.maxSatisfying(packageVersionsDeprecated, packageJsonRange); + + if (resolvedInstalledVersion && semver.satisfies(resolvedInstalledVersion, range)) { + continue; + } + } + } + } + + packages.set(peer, range as VersionRange); + } +} + +function _formatVersion(v?: string): string | undefined { + if (v === undefined) { + return v; + } + if (semver.valid(v)) { + return v; + } + const coerced = semver.coerce(v); + + return coerced ? coerced.toString() : undefined; +} + +function isPkgFromRegistry(name: string, specifier: string): boolean { + const result = npa.resolve(name, specifier); + + return !!result.registry; +} + +export async function resolveUserUpdatePlan( + options: UpdateResolverOptions, + logger: logging.LoggerApi, +): Promise { + const workspaceRoot = options.workspaceRoot ?? process.cwd(); + const packageJsonPath = path.join(workspaceRoot, 'package.json'); + if (!existsSync(packageJsonPath)) { + throw new Error('Could not find a package.json. Are you in a Node project?'); + } + + const rawJson = readFileSync(packageJsonPath, 'utf8'); + const packageJsonContent = JSON.parse(rawJson) as PackageManifest; + + const getDependencies = (deps: Record | undefined) => + Object.entries(deps ?? {}).map(([name, range]) => [name, range] as const); + + const allRawDeps = [ + ...getDependencies(packageJsonContent.dependencies), + ...getDependencies(packageJsonContent.devDependencies), + ...getDependencies(packageJsonContent.peerDependencies), + ]; + + const npmDeps = new Map( + allRawDeps.filter(([name, specifier]) => { + try { + return isPkgFromRegistry(name, specifier); + } catch { + logger.warn(`Package ${name} was not found on the registry. Skipping.`); + + return false; + } + }) as [string, VersionRange][], + ); + + const packagesOption = options.packages ?? []; + const normalizedPackages = packagesOption.reduce((acc, curr) => { + return acc.concat(curr.split(',')); + }, [] as string[]); + options.packages = normalizedPackages; + + if (options.migrateOnly && options.from) { + if (options.packages.length !== 1) { + throw new Error('--from requires that only a single package be passed.'); + } + } + + options.from = _formatVersion(options.from); + options.to = _formatVersion(options.to); + const usingYarn = options.packageManager === 'yarn'; + + const packages = _buildPackageList(options, npmDeps, logger); + const npmPackageJsonMap = new Map(); + + const getOrFetchPackageMetadata = async ( + packageName: string, + ): Promise => { + let metadata = npmPackageJsonMap.get(packageName); + if (!metadata) { + const raw = await getNpmPackageJson(packageName, logger, { + registry: options.registry, + usingYarn, + verbose: options.verbose, + }); + if (raw.name) { + metadata = raw as NpmRepositoryPackageJson; + npmPackageJsonMap.set(packageName, metadata); + } + } + + return metadata ?? null; + }; + + if (packages.size === 0) { + await Promise.all( + Array.from(npmDeps.keys()).map(async (depName) => { + await getOrFetchPackageMetadata(depName); + }), + ); + } else { + let lastPackagesSize; + do { + lastPackagesSize = packages.size; + + let lastGroupSize; + do { + lastGroupSize = packages.size; + for (const name of Array.from(packages.keys())) { + const metadata = await getOrFetchPackageMetadata(name); + const spec = packages.get(name); + if (metadata && spec) { + const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); + if (resolvedVersion) { + packages.set(name, resolvedVersion as VersionRange); + } + _addPackageGroup(packages, npmDeps, metadata, logger); + } + } + } while (packages.size > lastGroupSize); + + for (const name of Array.from(packages.keys())) { + const metadata = await getOrFetchPackageMetadata(name); + const spec = packages.get(name); + if (metadata && spec) { + const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); + if (resolvedVersion) { + packages.set(name, resolvedVersion as VersionRange); + } + await _addPeerDependencies( + packages, + npmDeps, + metadata, + workspaceRoot, + getOrFetchPackageMetadata, + logger, + ); + } + } + } while (packages.size > lastPackagesSize); + } + + const packageInfoMap = new Map(); + for (const depName of npmDeps.keys()) { + const isUpdating = packages.has(depName); + const localPkgJson = getInstalledPackageJson(depName, workspaceRoot); + + if (isUpdating || !localPkgJson) { + const metadata = await getOrFetchPackageMetadata(depName); + if (metadata) { + packageInfoMap.set( + depName, + _buildPackageInfo(packages, npmDeps, metadata, workspaceRoot, logger), + ); + } else { + packageInfoMap.set(depName, _buildLocalPackageInfo(depName, npmDeps, workspaceRoot)); + } + } else { + packageInfoMap.set(depName, _buildLocalPackageInfo(depName, npmDeps, workspaceRoot)); + } + } + + const packagesToUpdate = new Map(); + const migrationsToRun: { package: string; collection: string; from: string; to: string }[] = []; + + if (packages.size > 0) { + if (!(options.migrateOnly && options.from && options.packages)) { + const sublog = new logging.LevelCapLogger('validation', logger.createChild(''), 'warn'); + _validateUpdatePackages(packageInfoMap, !!options.force, !!options.next, sublog); + + for (const [name, info] of packageInfoMap.entries()) { + if (!info.target || !info.installed) { + continue; + } + packagesToUpdate.set(name, info.target.version); + + if (info.target.updateMetadata.migrations) { + migrationsToRun.push({ + package: name, + collection: info.target.updateMetadata.migrations, + from: info.installed.version, + to: info.target.version, + }); + } + } + } + } + + return { + packagesToUpdate, + migrationsToRun, + packageInfoMap, + }; +} + +export function printUpdateUsageMessage( + infoMap: Map, + logger: logging.LoggerApi, + next = false, +) { + const packageGroups = new Map(); + const packagesToUpdate = [...infoMap.entries()] + .map(([name, info]) => { + const distTags = info.npmPackageJson['dist-tags'] ?? {}; + let tag = next ? (distTags['next'] ? 'next' : 'latest') : 'latest'; + let version = distTags[tag] ?? info.installed.version; + const versions = info.npmPackageJson.versions ?? {}; + let target = 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(versions) + .filter((v) => v.startsWith(nextMajorVersion)) + .sort((a, b) => (a > b ? -1 : 1)); + + if (nextMajorVersions.length) { + version = nextMajorVersions[0]; + target = versions[version]; + tag = ''; + } + } + } + + return { + name, + info, + version, + tag, + target, + }; + }) + .filter( + ({ info, version, target }) => + target?.['ng-update'] && semver.compare(info.installed.version, version) < 0, + ) + .map(({ name, info, version, tag, target }) => { + // Look for packageGroup. + const ngUpdate = target['ng-update']; + const packageGroup = ngUpdate?.['packageGroup']; + if (packageGroup) { + const packageGroupNames = Array.isArray(packageGroup) + ? packageGroup + : Object.keys(packageGroup); + const packageGroupName = + ngUpdate?.['packageGroupName'] || packageGroupNames.find((n) => infoMap.has(n)); + + if (packageGroupName) { + if (packageGroups.has(name)) { + return null; + } + + for (const groupName of packageGroupNames) { + packageGroups.set(groupName, packageGroupName); + } + + packageGroups.set(packageGroupName, packageGroupName); + name = packageGroupName; + } + } + + let command = `ng update ${name}`; + if (!tag) { + command += `@${semver.parse(version)?.major || version}`; + } else if (tag == 'next') { + command += ' --next'; + } + + return [name, `${info.installed.version} -> ${version} `, command]; + }) + .filter((x): x is string[] => x !== null) + .sort((a, b) => a[0].localeCompare(b[0])); + + if (packagesToUpdate.length == 0) { + logger.info('We analyzed your package.json and everything seems to be in order. Good work!'); + + return; + } + + logger.info('We analyzed your package.json, there are some packages to update:\n'); + + // Find the largest name to know the padding needed. + let namePad = Math.max(...[...infoMap.keys()].map((x) => x.length)) + 2; + if (!Number.isFinite(namePad)) { + namePad = 30; + } + const pads = [namePad, 25, 0]; + + logger.info( + ' ' + ['Name', 'Version', 'Command to update'].map((x, i) => x.padEnd(pads[i])).join(''), + ); + + const totalWidth = pads.reduce((sum, width) => sum + width, 20); + logger.info(` ${'-'.repeat(totalWidth)}`); + + packagesToUpdate.forEach((fields) => { + if (!fields) { + return; + } + + logger.info(' ' + fields.map((x, i) => x.padEnd(pads[i])).join('')); + }); + + logger.info( + `\nThere might be additional packages which don't provide 'ng update' capabilities that are outdated.\n` + + `You can update the additional packages by running the update command of your package manager.`, + ); +} + +export async function applyUpdatePlan( + workspaceRoot: string, + plan: UpdatePlan, + logger: logging.LoggerApi, +): Promise { + const packageJsonPath = path.join(workspaceRoot, 'package.json'); + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent) as PackageManifest; + + const updateDependency = (deps: Record, name: string, newVersion: string) => { + const oldVersion = deps[name]; + const execResult = /^[\^~]/.exec(oldVersion); + deps[name] = `${execResult ? execResult[0] : ''}${newVersion}`; + }; + + for (const [name, targetVersion] of plan.packagesToUpdate.entries()) { + logger.info(`Updating package.json with dependency ${name} to version ${targetVersion}...`); + + if (packageJson.dependencies && packageJson.dependencies[name]) { + updateDependency(packageJson.dependencies, name, targetVersion); + if (packageJson.devDependencies) { + delete packageJson.devDependencies[name]; + } + if (packageJson.peerDependencies) { + delete packageJson.peerDependencies[name]; + } + } else if (packageJson.devDependencies && packageJson.devDependencies[name]) { + updateDependency(packageJson.devDependencies, name, targetVersion); + if (packageJson.peerDependencies) { + delete packageJson.peerDependencies[name]; + } + } else if (packageJson.peerDependencies && packageJson.peerDependencies[name]) { + updateDependency(packageJson.peerDependencies, name, targetVersion); + } else { + if (!packageJson.dependencies) { + packageJson.dependencies = {}; + } + packageJson.dependencies[name] = `^${targetVersion}`; + } + } + + const eofMatches = packageJsonContent.match(/\r?\n$/); + const eof = eofMatches?.[0] ?? ''; + const newContent = JSON.stringify(packageJson, null, 2) + eof; + await fs.writeFile(packageJsonPath, newContent, 'utf8'); +} diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts new file mode 100644 index 000000000000..6953b9817906 --- /dev/null +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -0,0 +1,230 @@ +/** + * @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 { logging } from '@angular-devkit/core'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import * as semver from 'semver'; +import { + angularMajorCompatGuarantee, + applyUpdatePlan, + resolveUserUpdatePlan, +} from './update-resolver'; + +describe('angularMajorCompatGuarantee', () => { + [ + '5.0.0', + '5.1.0', + '5.20.0', + '6.0.0', + '6.0.0-rc.0', + '6.0.0-beta.0', + '6.1.0-beta.0', + '6.1.0-rc.0', + '6.10.11', + ].forEach((golden) => { + it('works with ' + JSON.stringify(golden), () => { + expect(semver.satisfies(golden, angularMajorCompatGuarantee('^5.0.0'))).toBeTruthy(); + }); + }); +}); + +describe('UpdateResolver', () => { + let tempRoot: string; + const logger = new logging.NullLogger(); + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'angular-cli-update-resolver-test-')); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + function createMockWorkspace( + packageJson: Record, + nodeModules: { [name: string]: { version: string; manifest?: Record } } = {}, + ) { + writeFileSync(path.join(tempRoot, 'package.json'), JSON.stringify(packageJson, null, 2)); + for (const [name, info] of Object.entries(nodeModules)) { + const pkgDir = path.join(tempRoot, 'node_modules', name); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name, version: info.version, ...info.manifest }, null, 2), + ); + } + } + + it('ignores dependencies not hosted on the NPM registry', async () => { + createMockWorkspace({ + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-base': 'file:update-base-1.0.0.tgz', + }, + }); + + const plan = await resolveUserUpdatePlan( + { + packages: [], + workspaceRoot: tempRoot, + }, + logger, + ); + + expect(plan.packagesToUpdate.size).toBe(0); + }); + + it('should not error with yarn 2.0 protocols', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + src: 'src@link:./src', + '@angular-devkit-tests/update-base': '1.0.0', + }, + }, + { + '@angular-devkit-tests/update-base': { version: '1.0.0' }, + }, + ); + + const plan = await resolveUserUpdatePlan( + { + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }, + logger, + ); + + expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-base')).toBe('1.1.0'); + }); + + it('updates Angular as compatible with Angular N-1', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-peer-dependencies-angular-5': '1.0.0', + '@angular/core': '5.1.0', + rxjs: '5.5.0', + 'zone.js': '0.8.26', + }, + }, + { + '@angular-devkit-tests/update-peer-dependencies-angular-5': { version: '1.0.0' }, + '@angular/core': { version: '5.1.0' }, + rxjs: { version: '5.5.0' }, + 'zone.js': { version: '0.8.26' }, + }, + ); + + const plan = await resolveUserUpdatePlan( + { + packages: ['@angular/core@^6.0.0'], + workspaceRoot: tempRoot, + }, + logger, + ); + + expect(plan.packagesToUpdate.get('@angular/core')?.[0]).toBe('6'); + }); + + it('uses packageGroup for versioning', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-package-group-1': '1.0.0', + '@angular-devkit-tests/update-package-group-2': '1.0.0', + }, + }, + { + '@angular-devkit-tests/update-package-group-1': { version: '1.0.0' }, + '@angular-devkit-tests/update-package-group-2': { version: '1.0.0' }, + }, + ); + + const plan = await resolveUserUpdatePlan( + { + packages: ['@angular-devkit-tests/update-package-group-1'], + workspaceRoot: tempRoot, + }, + logger, + ); + + expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-package-group-1')).toBe('1.2.0'); + expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-package-group-2')).toBe('2.0.0'); + }); + + it('does not remove newline at the end of package.json', async () => { + const newline = '\n'; + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } +}${newline}`; + + writeFileSync(path.join(tempRoot, 'package.json'), packageJsonContent); + + // Mock installed package + const pkgDir = path.join(tempRoot, 'node_modules', '@angular-devkit-tests/update-base'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: '@angular-devkit-tests/update-base', version: '1.0.0' }, null, 2), + ); + + const plan = await resolveUserUpdatePlan( + { + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }, + logger, + ); + + await applyUpdatePlan(tempRoot, plan, logger); + + const result = readFileSync(path.join(tempRoot, 'package.json'), 'utf8'); + expect(result.endsWith(newline)).toBeTrue(); + }); + + it('does not add a newline at the end of package.json', async () => { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } +}`; + + writeFileSync(path.join(tempRoot, 'package.json'), packageJsonContent); + + // Mock installed package + const pkgDir = path.join(tempRoot, 'node_modules', '@angular-devkit-tests/update-base'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: '@angular-devkit-tests/update-base', version: '1.0.0' }, null, 2), + ); + + const plan = await resolveUserUpdatePlan( + { + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }, + logger, + ); + + await applyUpdatePlan(tempRoot, plan, logger); + + const result = readFileSync(path.join(tempRoot, 'package.json'), 'utf8'); + expect(result.endsWith('}')).toBeTrue(); + }); +});