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(); + }); +});