diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae4c87..494f632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@red-hat-developer-hub/cli` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.0.6 - 2026-09-11 + +### Added + +- **`plugin upgrade`:** Add `rhdh-cli plugin upgrade ` (alias `plugin versions:bump`) command ([RHIDP-16666](https://redhat.atlassian.net/browse/RHIDP-16666)). Automatically aligns all `@backstage/*` package dependencies in `package.json` (`dependencies`, `devDependencies`, `peerDependencies`) and `backstage.json` to the exact manifest versions for a target RHDH release, preserving range specifiers and non-manifest dependencies. Supports `--dry-run`, `--skip-install`, and offline `--manifest-file` options. + ## 2.0.5 - 2026-09-04 ### Added diff --git a/README.md b/README.md index 6f9c49f..1527ac6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,20 @@ For air-gapped environments, provide a local release manifest with `--manifest-f When adding support for a new RHDH release, update `RHDH_COMPATIBILITY_MATRIX` in `src/lib/rhdhVersion.ts` with its Backstage version before releasing the corresponding CLI version. This matrix is maintained manually until its release metadata can be automated. +## Upgrading Plugin Versions + +Use `plugin upgrade` to update a plugin's `@backstage/*` dependencies to the versions from an RHDH release manifest: + +```bash +rhdh-cli plugin upgrade --rhdh-version 2.0.0 +``` + +The command also accepts the RHDH version as a positional argument, for example `rhdh-cli plugin upgrade 2.0.0`. Its `plugin versions:bump` alias provides the same behavior. + +Use `--dry-run` to preview dependency changes without writing files and `--skip-install` to avoid updating the lockfile after applying changes. Use `--json` for machine-readable output. + +For air-gapped environments, provide a local Backstage release manifest with `--manifest-file` and set `RHDH_OFFLINE=true` to skip the RHDH GitHub metadata lookup. + ## Development ### Contributing diff --git a/package.json b/package.json index fe9bb29..16af1f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@red-hat-developer-hub/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "2.0.5", + "version": "2.0.6", "publishConfig": { "access": "public" }, diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index 735993a..75f9d3d 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -22,6 +22,7 @@ import semver from 'semver'; import { ExitCodeError } from '../../lib/errors'; import { paths } from '../../lib/paths'; +import { DependencySection } from '../../lib/pluginDependencies'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; import { Task } from '../../lib/tasks'; @@ -30,10 +31,6 @@ export type DependencyStatus = | 'mismatch' | 'unmanifested' | 'unverifiable'; -export type DependencySection = - | 'dependencies' - | 'devDependencies' - | 'peerDependencies'; export interface PackageCheckResult { name: string; diff --git a/src/commands/index.ts b/src/commands/index.ts index ab4c0db..3fbb95c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -162,6 +162,31 @@ export function registerPluginCommand(program: Command) { ) .option('--json', 'Output results as JSON') .action(lazy(() => import('./check-versions').then(m => m.command))); + + command + .command('upgrade [rhdhVersion]') + .alias('versions:bump') + .description( + 'Upgrade dynamic plugin dependencies in package.json to match a target RHDH release', + ) + .option( + '--rhdh-version ', + 'Target RHDH version to upgrade compatibility to (e.g. 2.0.0, 1.9, latest, backstage:1.54.0)', + ) + .option( + '--dry-run', + 'Display planned dependency updates without modifying files on disk', + ) + .option( + '--skip-install', + 'Do not run package manager install after updating dependencies', + ) + .option( + '--manifest-file ', + 'Path to local Backstage release manifest JSON file (for offline usage)', + ) + .option('--json', 'Output upgrade results as JSON') + .action(lazy(() => import('./upgrade').then(m => m.command))); } export function registerCommands(program: Command) { diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts new file mode 100644 index 0000000..0d3cd62 --- /dev/null +++ b/src/commands/upgrade/command.test.ts @@ -0,0 +1,416 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import { ExitCodeError } from '../../lib/errors'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { Task } from '../../lib/tasks'; +import { + command, + computeTargetVersion, + detectPackageManager, + upgradePluginDependencies, +} from './command'; + +jest.mock('../../lib/rhdhVersion', () => ({ + ...jest.requireActual('../../lib/rhdhVersion'), + resolveRhdhVersion: jest.fn(), +})); + +describe('upgrade command', () => { + let tmpDir: string; + let originalCwd: string; + const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction< + typeof resolveRhdhVersion + >; + let mockTaskForCommand: jest.SpiedFunction; + + async function setupFixture( + pkg: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, + manifestPackages: [string, string][] = [ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/cli', '0.36.3'], + ['@backstage/config', '1.3.8'], + ], + backstageJsonVersion?: string, + ) { + await fs.writeJson(path.join(tmpDir, 'package.json'), { + name: 'test-plugin', + ...pkg, + }); + + if (backstageJsonVersion) { + await fs.writeJson(path.join(tmpDir, 'backstage.json'), { + version: backstageJsonVersion, + }); + } + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map(manifestPackages), + }); + } + + async function runCommandWithOutput(rhdhVersion?: string, opts: any = {}) { + let stdout = ''; + let stderr = ''; + let error: Error | undefined; + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: any) => { + stdout += chunk; + return true; + }); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: any) => { + stderr += chunk; + return true; + }); + + try { + await command(rhdhVersion, opts); + } catch (caught) { + error = caught as Error; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + return { stdout, stderr, error }; + } + + beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'upgrade-test-')); + process.chdir(tmpDir); + process.exitCode = undefined; + jest.clearAllMocks(); + mockTaskForCommand = jest + .spyOn(Task, 'forCommand') + .mockResolvedValue(undefined); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tmpDir); + process.exitCode = undefined; + mockTaskForCommand.mockRestore(); + }); + + describe('computeTargetVersion', () => { + it('preserves caret prefix', () => { + expect(computeTargetVersion('^1.10.0', '1.12.0')).toBe('^1.12.0'); + }); + + it('preserves tilde prefix', () => { + expect(computeTargetVersion('~1.10.0', '1.12.0')).toBe('~1.12.0'); + }); + + it('preserves comparator prefixes', () => { + expect(computeTargetVersion('>=1.10.0', '1.12.0')).toBe('>=1.12.0'); + expect(computeTargetVersion('workspace:^1.10.0', '1.12.0')).toBe( + 'workspace:^1.12.0', + ); + expect(computeTargetVersion('workspace:1.10.0', '1.12.0')).toBe( + 'workspace:1.12.0', + ); + }); + + it('leaves compound ranges unchanged', () => { + expect(computeTargetVersion('>=1.10.0 <2.0.0', '1.12.0')).toBe( + '>=1.10.0 <2.0.0', + ); + }); + + it('leaves unsupported ranges unchanged', () => { + expect(computeTargetVersion('*', '1.12.0')).toBe('*'); + expect(computeTargetVersion('workspace:*', '1.12.0')).toBe('workspace:*'); + }); + + it('preserves exact version pin', () => { + expect(computeTargetVersion('1.10.0', '1.12.0')).toBe('1.12.0'); + }); + + it('preserves backstage:^ protocol', () => { + expect(computeTargetVersion('backstage:^', '1.12.0')).toBe('backstage:^'); + }); + }); + + describe('detectPackageManager', () => { + it('detects yarn when yarn.lock is present', async () => { + await fs.writeFile(path.join(tmpDir, 'yarn.lock'), ''); + const pm = await detectPackageManager(tmpDir); + expect(pm).toBe('yarn'); + }); + + it('defaults to npm when yarn.lock is absent', async () => { + const pm = await detectPackageManager(tmpDir); + expect(pm).toBe('npm'); + }); + }); + + describe('upgradePluginDependencies', () => { + it('throws error when package.json is missing', async () => { + await expect( + upgradePluginDependencies({ targetDir: tmpDir }), + ).rejects.toThrow(/No package\.json found/); + }); + + it('updates package.json dependencies and backstage.json to match manifest', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + lodash: '^4.17.21', + }, + devDependencies: { + '@backstage/cli': '~0.30.0', + }, + }, + [ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/cli', '0.36.3'], + ], + '1.45.3', + ); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + skipInstall: true, + }); + + expect(result.rhdhVersion).toBe('2.0.0'); + expect(result.backstageVersion).toBe('1.52.0'); + expect(result.updatedFiles).toContain('package.json'); + expect(result.updatedFiles).toContain('backstage.json'); + + const updatedPkg = await fs.readJson(path.join(tmpDir, 'package.json')); + expect(updatedPkg.dependencies['@backstage/core-plugin-api']).toBe( + '^1.12.0', + ); + expect(updatedPkg.dependencies.lodash).toBe('^4.17.21'); // Untouched + expect(updatedPkg.devDependencies['@backstage/cli']).toBe('~0.36.3'); + + const updatedBsJson = await fs.readJson( + path.join(tmpDir, 'backstage.json'), + ); + expect(updatedBsJson.version).toBe('1.52.0'); + }); + + it('does not write changes in dry-run mode', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + dryRun: true, + }); + + expect(result.updatedFiles).toEqual([]); + expect(result.wouldUpdateFiles).toEqual(['package.json']); + const pkg = await fs.readJson(path.join(tmpDir, 'package.json')); + expect(pkg.dependencies['@backstage/core-plugin-api']).toBe('^1.9.0'); + }); + + it('reports backstage.json as a dry-run change', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + }, + }, + undefined, + '1.45.3', + ); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + dryRun: true, + }); + + expect(result.updatedFiles).toEqual([]); + expect(result.wouldUpdateFiles).toEqual(['backstage.json']); + }); + + it('propagates backstage.json write failures', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + }, + }, + undefined, + '1.45.3', + ); + const writeJsonSpy = jest + .spyOn(fs, 'writeJson') + .mockRejectedValue(new Error('disk full')); + + await expect( + upgradePluginDependencies({ targetDir: tmpDir, skipInstall: true }), + ).rejects.toThrow('disk full'); + + writeJsonSpy.mockRestore(); + }); + + it('warns when backstage.json cannot be parsed', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + }, + }, + undefined, + ); + await fs.writeFile(path.join(tmpDir, 'backstage.json'), '{ invalid json'); + + const res = await runCommandWithOutput('2.0.0', { skipInstall: true }); + + expect(res.stderr).toContain('Could not parse backstage.json'); + expect(res.stderr).toContain('skipping its version update'); + }); + + it('deduplicates unmanifested @backstage packages', async () => { + await setupFixture( + { + dependencies: { + '@backstage/unknown-pkg': '^1.0.0', + }, + peerDependencies: { + '@backstage/unknown-pkg': '^1.0.0', + }, + }, + [], + ); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + skipInstall: true, + }); + + expect(result.unmanifested).toEqual(['@backstage/unknown-pkg']); + }); + + it('runs package manager install unless skipInstall is true', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + skipInstall: false, + }); + + expect(result.installed).toBe(true); + expect(mockTaskForCommand).toHaveBeenCalledWith('npm install', { + cwd: tmpDir, + }); + }); + + it('reports a failed install to human users', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + mockTaskForCommand.mockRejectedValue(new Error('install failed')); + + const res = await runCommandWithOutput('2.0.0'); + + expect(res.stderr).toContain('installation failed'); + expect(res.stderr).not.toContain('Successfully upgraded'); + expect(res.error).toEqual(new ExitCodeError(1)); + }); + }); + + describe('CLI command handler', () => { + it('outputs JSON when --json flag is passed', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + const res = await runCommandWithOutput('2.0.0', { + json: true, + skipInstall: true, + }); + const parsed = JSON.parse(res.stdout); + expect(parsed.rhdhVersion).toBe('2.0.0'); + expect(parsed.backstageVersion).toBe('1.52.0'); + expect(parsed.changes).toHaveLength(1); + expect(parsed.changes[0].changed).toBe(true); + }); + + it('prints formatted table and summary in human mode', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + const res = await runCommandWithOutput('2.0.0', { skipInstall: true }); + expect(res.stderr).toContain('Package'); + expect(res.stderr).toContain('@backstage/core-plugin-api'); + expect(res.stderr).toContain('updated'); + expect(res.stderr).toContain('Successfully upgraded'); + }); + + it('prints dry-run notification in dry-run mode', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + const res = await runCommandWithOutput('2.0.0', { dryRun: true }); + expect(res.stderr).toContain('Dry run completed'); + expect(res.stderr).toContain('Would update package.json'); + }); + + it('warns when all Backstage dependencies are unmanifested', async () => { + await setupFixture( + { + dependencies: { + '@backstage/unknown-pkg': '^1.0.0', + }, + }, + [], + ); + + const res = await runCommandWithOutput('2.0.0', { skipInstall: true }); + + expect(res.stderr).toContain('@backstage/unknown-pkg'); + expect(res.stderr).toContain('not present in the release manifest'); + expect(res.stderr).not.toContain('No @backstage dependencies found'); + }); + }); +}); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts new file mode 100644 index 0000000..4abf506 --- /dev/null +++ b/src/commands/upgrade/command.ts @@ -0,0 +1,419 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BACKSTAGE_JSON } from '@backstage/cli-common'; +import chalk from 'chalk'; +import { OptionValues } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import semver from 'semver'; + +import { ExitCodeError } from '../../lib/errors'; +import { paths } from '../../lib/paths'; +import { DependencySection } from '../../lib/pluginDependencies'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { Task } from '../../lib/tasks'; + +export interface PackageUpgradeChange { + name: string; + section: DependencySection; + current: string; + target: string; + changed: boolean; +} + +export interface UpgradeOptions { + rhdhVersion?: string; + dryRun?: boolean; + skipInstall?: boolean; + manifestFile?: string; + targetDir?: string; +} + +export interface UpgradeResult { + rhdhVersion: string; + backstageVersion: string; + source: 'remote' | 'matrix' | 'explicit'; + changes: PackageUpgradeChange[]; + unmanifested: string[]; + updatedFiles: string[]; + wouldUpdateFiles: string[]; + installed: boolean; +} + +/** + * Computes the target version string preserving existing range specifier (^, ~) or exact pin + */ +export function computeTargetVersion( + currentDeclared: string, + manifestExpected: string, +): string { + if (currentDeclared === 'backstage:^') { + return 'backstage:^'; + } + + if (currentDeclared.startsWith('workspace:')) { + const workspaceVersion = currentDeclared.slice('workspace:'.length); + const targetVersion = computeTargetVersion( + workspaceVersion, + manifestExpected, + ); + return targetVersion === workspaceVersion + ? currentDeclared + : `workspace:${targetVersion}`; + } + + const rangePrefix = /^(?:\^|~|>=|<=|>|<|=)/.exec(currentDeclared)?.[0]; + if (rangePrefix) { + const declaredVersion = currentDeclared.slice(rangePrefix.length); + if (semver.valid(declaredVersion)) { + return `${rangePrefix}${manifestExpected}`; + } + } + + if (semver.valid(currentDeclared)) { + return manifestExpected; + } + + return currentDeclared; +} + +/** + * Detects whether the project uses yarn or npm + */ +export async function detectPackageManager( + targetDir: string, +): Promise<'yarn' | 'npm'> { + const possibleYarnLocks = [ + path.join(targetDir, 'yarn.lock'), + path.join(paths.targetRoot, 'yarn.lock'), + ]; + + for (const lockPath of possibleYarnLocks) { + if (await fs.pathExists(lockPath)) { + return 'yarn'; + } + } + + return 'npm'; +} + +/** + * Applies upgrades across all dependency sections in package.json + */ +function applyDependencyUpgrades( + packageJson: Record, + manifestPackages: Map, +): { + changes: PackageUpgradeChange[]; + unmanifested: string[]; + modified: boolean; +} { + const sections: DependencySection[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]; + + const changes: PackageUpgradeChange[] = []; + const unmanifested = new Set(); + let modified = false; + + for (const section of sections) { + const deps = packageJson[section] as Record | undefined; + if (!deps) continue; + + for (const [name, currentVersion] of Object.entries(deps)) { + const isBackstagePkg = name.startsWith('@backstage/'); + const manifestExpected = manifestPackages.get(name); + + if (!isBackstagePkg && !manifestExpected) { + continue; + } + + if (!manifestExpected) { + unmanifested.add(name); + continue; + } + + const targetVersion = computeTargetVersion( + currentVersion, + manifestExpected, + ); + const isChanged = currentVersion !== targetVersion; + + changes.push({ + name, + section, + current: currentVersion, + target: targetVersion, + changed: isChanged, + }); + + if (isChanged) { + packageJson[section][name] = targetVersion; + modified = true; + } + } + } + + return { changes, unmanifested: Array.from(unmanifested), modified }; +} + +/** + * Synchronizes backstage.json with target Backstage version if present + */ +async function syncBackstageJson( + targetDir: string, + targetBackstageVersion: string, + dryRun: boolean, +): Promise { + const backstageJsonPath = path.join(targetDir, BACKSTAGE_JSON); + if (!(await fs.pathExists(backstageJsonPath))) { + return undefined; + } + + let backstageJson: { version?: string }; + try { + backstageJson = await fs.readJson(backstageJsonPath); + } catch { + process.stderr.write( + `${chalk.yellow('Warning:')} Could not parse ${BACKSTAGE_JSON}; skipping its version update.\n`, + ); + return undefined; + } + + if (backstageJson.version !== targetBackstageVersion) { + if (!dryRun) { + backstageJson.version = targetBackstageVersion; + await fs.writeJson(backstageJsonPath, backstageJson, { spaces: 2 }); + } + return BACKSTAGE_JSON; + } + + return undefined; +} + +/** + * Executes package manager install in target directory + */ +async function runInstallDependencies(targetDir: string): Promise { + const pm = await detectPackageManager(targetDir); + try { + await Task.forCommand(`${pm} install`, { cwd: targetDir }); + return true; + } catch { + return false; + } +} + +/** + * Upgrades @backstage/* dependencies in a package.json to match target RHDH release manifest + */ +export async function upgradePluginDependencies( + options: UpgradeOptions = {}, +): Promise { + const targetDir = options.targetDir || paths.targetDir; + const packageJsonPath = path.join(targetDir, 'package.json'); + + if (!(await fs.pathExists(packageJsonPath))) { + throw new Error( + `No package.json found at "${targetDir}". Make sure you run this command inside a plugin package directory.`, + ); + } + + const packageJson = await fs.readJson(packageJsonPath); + const resolved = await resolveRhdhVersion(options.rhdhVersion, { + manifestFile: options.manifestFile, + }); + + const { changes, unmanifested, modified } = applyDependencyUpgrades( + packageJson, + resolved.packages, + ); + + const updatedFiles: string[] = []; + const wouldUpdateFiles: string[] = []; + let installed = false; + + if (modified) { + if (options.dryRun) { + wouldUpdateFiles.push('package.json'); + } else { + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + updatedFiles.push('package.json'); + } + } + + const updatedBsJson = await syncBackstageJson( + targetDir, + resolved.backstageVersion, + Boolean(options.dryRun), + ); + if (updatedBsJson) { + if (options.dryRun) { + wouldUpdateFiles.push(updatedBsJson); + } else { + updatedFiles.push(updatedBsJson); + } + } + + if (!options.dryRun && !options.skipInstall && modified) { + installed = await runInstallDependencies(targetDir); + } + + return { + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + source: resolved.source, + changes, + unmanifested, + updatedFiles, + wouldUpdateFiles, + installed, + }; +} + +/** + * Renders human-readable results for a plugin dependency upgrade. + */ +function printUpgradeResult( + result: UpgradeResult, + dryRun: boolean, + installFailed: boolean, + changedCount: number, +): boolean { + const modeLabel = dryRun ? ' (dry run)' : ''; + Task.log( + `Upgrading plugin dependencies to RHDH v${result.rhdhVersion} (Backstage v${result.backstageVersion}) [${result.source}]${modeLabel}...`, + ); + + if (result.unmanifested.length > 0) { + const unmanCountStr = chalk.yellow( + `${result.unmanifested.length} unmanifested`, + ); + process.stderr.write( + `\n${chalk.yellow('Warning:')} Found ${unmanCountStr} @backstage packages not present in the release manifest: ${result.unmanifested.join(', ')}\n`, + ); + } + + if (result.changes.length === 0) { + Task.log('No manifest-matched @backstage dependencies found to upgrade.'); + return false; + } + + process.stderr.write('\n'); + + const colNameWidth = Math.max( + ...result.changes.map(c => c.name.length), + 'Package'.length, + ); + const colSecWidth = Math.max( + ...result.changes.map(c => c.section.length), + 'Section'.length, + ); + const colCurWidth = Math.max( + ...result.changes.map(c => c.current.length), + 'Current'.length, + ); + const colTarWidth = Math.max( + ...result.changes.map(c => c.target.length), + 'Target'.length, + ); + + const header = `${'Package'.padEnd(colNameWidth)} ${'Section'.padEnd(colSecWidth)} ${'Current'.padEnd(colCurWidth)} ${'Target'.padEnd(colTarWidth)} Status`; + process.stderr.write(`${chalk.bold(header)}\n`); + process.stderr.write(`${chalk.gray('-'.repeat(header.length))}\n`); + + for (const change of result.changes) { + const statusLabel = change.changed + ? chalk.yellow('↻ updated') + : chalk.green('✓ unchanged'); + + const line = `${change.name.padEnd(colNameWidth)} ${change.section.padEnd(colSecWidth)} ${change.current.padEnd(colCurWidth)} ${change.target.padEnd(colTarWidth)} ${statusLabel}`; + process.stderr.write(`${line}\n`); + } + + process.stderr.write('\n'); + + const unchangedCount = result.changes.filter(c => !c.changed).length; + + const updatedStr = chalk.yellow(`↻ ${changedCount} updated`); + const unchangedStr = chalk.green(`✓ ${unchangedCount} unchanged`); + const summary = `${updatedStr}, ${unchangedStr} (${result.changes.length} total)`; + process.stderr.write(`${chalk.bold('Summary:')} ${summary}\n`); + + if (dryRun) { + const filesStr = result.wouldUpdateFiles.length + ? ` Would update ${chalk.cyan(result.wouldUpdateFiles.join(', '))}.` + : ''; + process.stderr.write( + `\n${chalk.cyan('Dry run completed:')} No files were modified on disk.${filesStr}\n\n`, + ); + } else if (installFailed) { + Task.error( + 'Dependencies were updated, but installation failed. Resolve the installation error and retry.', + ); + return true; + } else if (result.updatedFiles.length > 0) { + const filesStr = chalk.cyan(result.updatedFiles.join(', ')); + process.stderr.write( + `\n${chalk.green('✔ Successfully upgraded')} ${filesStr}.\n\n`, + ); + } else { + process.stderr.write( + `\n${chalk.green('✔ All dependencies are already up to date.')}\n\n`, + ); + } + + return false; +} + +/** + * CLI command entry point for `rhdh-cli plugin upgrade` + */ +export async function command( + rhdhVersionArg?: string, + opts: OptionValues = {}, +): Promise { + const rhdhVersion = rhdhVersionArg || opts.rhdhVersion; + const { dryRun, skipInstall, manifestFile, json } = opts; + + const result = await upgradePluginDependencies({ + rhdhVersion, + dryRun, + skipInstall, + manifestFile, + }); + + const changedCount = result.changes.filter(c => c.changed).length; + const installFailed = + !dryRun && !skipInstall && changedCount > 0 && !result.installed; + + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (installFailed) { + throw new ExitCodeError(1); + } + return; + } + + if ( + printUpgradeResult(result, Boolean(dryRun), installFailed, changedCount) + ) { + throw new ExitCodeError(1); + } +} diff --git a/src/commands/upgrade/index.ts b/src/commands/upgrade/index.ts new file mode 100644 index 0000000..ebaffcf --- /dev/null +++ b/src/commands/upgrade/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { command } from './command'; diff --git a/src/lib/pluginDependencies.ts b/src/lib/pluginDependencies.ts new file mode 100644 index 0000000..fd2a4f2 --- /dev/null +++ b/src/lib/pluginDependencies.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type DependencySection = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies';