From f968709ab901fa1952029da58007a09e2e88ec7e Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:38:36 -0400 Subject: [PATCH 01/11] feat: add plugin upgrade command for dynamic plugin dependency management (RHIDP-16666) Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- CHANGELOG.md | 1 + src/commands/index.ts | 21 ++ src/commands/upgrade/command.test.ts | 301 +++++++++++++++++++++++++ src/commands/upgrade/command.ts | 318 +++++++++++++++++++++++++++ src/commands/upgrade/index.ts | 17 ++ 5 files changed, 658 insertions(+) create mode 100644 src/commands/upgrade/command.test.ts create mode 100644 src/commands/upgrade/command.ts create mode 100644 src/commands/upgrade/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae4c87..c49c998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. - **`plugin check-versions`:** Add `rhdh-cli plugin check-versions` (alias `plugin versions:lint`) command and RHDH-to-Backstage version mapping engine ([RHIDP-16665](https://redhat.atlassian.net/browse/RHIDP-16665), [RHIDP-16667](https://redhat.atlassian.net/browse/RHIDP-16667), [#176](https://github.com/redhat-developer/rhdh-cli/pull/176)). Supports auditing `@backstage/*` dependencies in `package.json` against target RHDH release manifests using a 3-tier resolution engine (remote GitHub build-metadata, embedded static compatibility matrix fallback, and Backstage release manifests). ## 2.0.4 - 2026-08-27 diff --git a/src/commands/index.ts b/src/commands/index.ts index ab4c0db..85096c3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -162,6 +162,27 @@ 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( + '--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..926766d --- /dev/null +++ b/src/commands/upgrade/command.test.ts @@ -0,0 +1,301 @@ +/* + * 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 'os'; +import path from 'node:path'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import * as runMod from '../../lib/run'; +import { + command, + computeTargetVersion, + detectPackageManager, + upgradePluginDependencies, +} from './command'; + +jest.mock('../../lib/rhdhVersion', () => ({ + ...jest.requireActual('../../lib/rhdhVersion'), + resolveRhdhVersion: jest.fn(), +})); + +jest.mock('../../lib/run', () => ({ + ...jest.requireActual('../../lib/run'), + runPlain: jest.fn(), +})); + +describe('upgrade command', () => { + let tmpDir: string; + let originalCwd: string; + const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction< + typeof resolveRhdhVersion + >; + const mockRunPlain = runMod.runPlain as jest.MockedFunction< + typeof runMod.runPlain + >; + + 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 = ''; + 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); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + return { stdout, stderr }; + } + + beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'upgrade-test-')); + process.chdir(tmpDir); + process.exitCode = undefined; + jest.clearAllMocks(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tmpDir); + process.exitCode = undefined; + }); + + describe('computeTargetVersion', () => { + it('preserves carat 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 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([]); + const pkg = await fs.readJson(path.join(tmpDir, 'package.json')); + expect(pkg.dependencies['@backstage/core-plugin-api']).toBe('^1.9.0'); + }); + + it('tracks unmanifested @backstage packages', async () => { + await setupFixture( + { + dependencies: { + '@backstage/unknown-pkg': '^1.0.0', + }, + }, + [], + ); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + skipInstall: true, + }); + + expect(result.unmanifested).toContain('@backstage/unknown-pkg'); + }); + + it('runs package manager install unless skipInstall is true', async () => { + await setupFixture({ + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }); + + mockRunPlain.mockResolvedValue(''); + + const result = await upgradePluginDependencies({ + targetDir: tmpDir, + skipInstall: false, + }); + + expect(result.installed).toBe(true); + expect(mockRunPlain).toHaveBeenCalledWith( + expect.stringMatching(/yarn|npm/), + 'install', + ); + }); + }); + + 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.length).toBe(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'); + }); + }); +}); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts new file mode 100644 index 0000000..df10fc6 --- /dev/null +++ b/src/commands/upgrade/command.ts @@ -0,0 +1,318 @@ +/* + * 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 { paths } from '../../lib/paths'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { runPlain } from '../../lib/run'; +import { Task } from '../../lib/tasks'; + +export type DependencySection = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies'; + +export interface PackageUpgradeChange { + name: string; + section: DependencySection; + current: string; + target: string; + changed: boolean; +} + +export interface UpgradePluginOptions { + rhdhVersion?: string; + dryRun?: boolean; + skipInstall?: boolean; + manifestFile?: string; + json?: boolean; + targetDir?: string; +} + +export interface UpgradePluginResult { + rhdhVersion: string; + backstageVersion: string; + source: 'remote' | 'matrix'; + changes: PackageUpgradeChange[]; + unmanifested: string[]; + updatedFiles: 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('^')) { + return `^${manifestExpected}`; + } + + if (currentDeclared.startsWith('~')) { + return `~${manifestExpected}`; + } + + return manifestExpected; +} + +/** + * 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'; +} + +/** + * Upgrades @backstage/* dependencies in a package.json to match target RHDH release manifest + */ +export async function upgradePluginDependencies( + options: UpgradePluginOptions = {}, +): 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 sections: DependencySection[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]; + + const changes: PackageUpgradeChange[] = []; + const unmanifested: string[] = []; + let packageJsonModified = 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 = resolved.packages.get(name); + + if (!isBackstagePkg && !manifestExpected) { + continue; + } + + if (!manifestExpected) { + unmanifested.push(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; + packageJsonModified = true; + } + } + } + + const updatedFiles: string[] = []; + let installed = false; + + if (!options.dryRun) { + if (packageJsonModified) { + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + updatedFiles.push('package.json'); + } + + // Update or create backstage.json if needed + const backstageJsonPath = path.join(targetDir, BACKSTAGE_JSON); + if (await fs.pathExists(backstageJsonPath)) { + try { + const backstageJson = await fs.readJson(backstageJsonPath); + if (backstageJson.version !== resolved.backstageVersion) { + backstageJson.version = resolved.backstageVersion; + await fs.writeJson(backstageJsonPath, backstageJson, { spaces: 2 }); + updatedFiles.push(BACKSTAGE_JSON); + } + } catch { + // Ignore JSON read errors + } + } + + // Run package manager install unless skipped + if (!options.skipInstall && packageJsonModified) { + const pm = await detectPackageManager(targetDir); + try { + await Task.forItem('installing', 'dependencies', async () => { + await runPlain(pm, 'install'); + }); + installed = true; + } catch { + // Logged by task / caller + } + } + } + + return { + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + source: resolved.source, + changes, + unmanifested, + updatedFiles, + installed, + }; +} + +/** + * 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, + json, + }); + + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + const modeLabel = dryRun ? ' (dry run)' : ''; + Task.log( + `Upgrading plugin dependencies to RHDH v${result.rhdhVersion} (Backstage v${result.backstageVersion}) [${result.source}]${modeLabel}...`, + ); + + if (result.changes.length === 0) { + Task.log('No @backstage dependencies found to upgrade.'); + return; + } + + 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 + ' Status'.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 changedCount = result.changes.filter(c => c.changed).length; + 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 (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 (dryRun) { + process.stderr.write( + `\n${chalk.cyan('Dry run completed:')} No files were modified on disk.\n\n`, + ); + } 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`, + ); + } +} 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'; From 902375e95ad0bb25af1a7cd5c000a04a2574fac7 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:56:17 -0400 Subject: [PATCH 02/11] refactor: reduce cognitive complexity in upgrade command and use toHaveLength in test Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.test.ts | 2 +- src/commands/upgrade/command.ts | 135 ++++++++++++++++++--------- 2 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 926766d..ecc36ca 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -269,7 +269,7 @@ describe('upgrade command', () => { const parsed = JSON.parse(res.stdout); expect(parsed.rhdhVersion).toBe('2.0.0'); expect(parsed.backstageVersion).toBe('1.52.0'); - expect(parsed.changes.length).toBe(1); + expect(parsed.changes).toHaveLength(1); expect(parsed.changes[0].changed).toBe(true); }); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index df10fc6..1cf1ae5 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -99,25 +99,16 @@ export async function detectPackageManager( } /** - * Upgrades @backstage/* dependencies in a package.json to match target RHDH release manifest + * Applies upgrades across all dependency sections in package.json */ -export async function upgradePluginDependencies( - options: UpgradePluginOptions = {}, -): 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, - }); - +function applyDependencyUpgrades( + packageJson: Record, + manifestPackages: Map, +): { + changes: PackageUpgradeChange[]; + unmanifested: string[]; + modified: boolean; +} { const sections: DependencySection[] = [ 'dependencies', 'devDependencies', @@ -126,7 +117,7 @@ export async function upgradePluginDependencies( const changes: PackageUpgradeChange[] = []; const unmanifested: string[] = []; - let packageJsonModified = false; + let modified = false; for (const section of sections) { const deps = packageJson[section] as Record | undefined; @@ -134,7 +125,7 @@ export async function upgradePluginDependencies( for (const [name, currentVersion] of Object.entries(deps)) { const isBackstagePkg = name.startsWith('@backstage/'); - const manifestExpected = resolved.packages.get(name); + const manifestExpected = manifestPackages.get(name); if (!isBackstagePkg && !manifestExpected) { continue; @@ -161,46 +152,98 @@ export async function upgradePluginDependencies( if (isChanged) { packageJson[section][name] = targetVersion; - packageJsonModified = true; + modified = true; } } } + return { changes, unmanifested, modified }; +} + +/** + * Synchronizes backstage.json with target Backstage version if present + */ +async function syncBackstageJson( + targetDir: string, + targetBackstageVersion: string, +): Promise { + const backstageJsonPath = path.join(targetDir, BACKSTAGE_JSON); + if (!(await fs.pathExists(backstageJsonPath))) { + return undefined; + } + + try { + const backstageJson = await fs.readJson(backstageJsonPath); + if (backstageJson.version !== targetBackstageVersion) { + backstageJson.version = targetBackstageVersion; + await fs.writeJson(backstageJsonPath, backstageJson, { spaces: 2 }); + return BACKSTAGE_JSON; + } + } catch { + // Ignore JSON read errors + } + return undefined; +} + +/** + * Executes package manager install in target directory + */ +async function runInstallDependencies(targetDir: string): Promise { + const pm = await detectPackageManager(targetDir); + try { + await Task.forItem('installing', 'dependencies', async () => { + await runPlain(pm, 'install'); + }); + return true; + } catch { + return false; + } +} + +/** + * Upgrades @backstage/* dependencies in a package.json to match target RHDH release manifest + */ +export async function upgradePluginDependencies( + options: UpgradePluginOptions = {}, +): 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[] = []; let installed = false; if (!options.dryRun) { - if (packageJsonModified) { + if (modified) { await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); updatedFiles.push('package.json'); } - // Update or create backstage.json if needed - const backstageJsonPath = path.join(targetDir, BACKSTAGE_JSON); - if (await fs.pathExists(backstageJsonPath)) { - try { - const backstageJson = await fs.readJson(backstageJsonPath); - if (backstageJson.version !== resolved.backstageVersion) { - backstageJson.version = resolved.backstageVersion; - await fs.writeJson(backstageJsonPath, backstageJson, { spaces: 2 }); - updatedFiles.push(BACKSTAGE_JSON); - } - } catch { - // Ignore JSON read errors - } + const updatedBsJson = await syncBackstageJson( + targetDir, + resolved.backstageVersion, + ); + if (updatedBsJson) { + updatedFiles.push(updatedBsJson); } - // Run package manager install unless skipped - if (!options.skipInstall && packageJsonModified) { - const pm = await detectPackageManager(targetDir); - try { - await Task.forItem('installing', 'dependencies', async () => { - await runPlain(pm, 'install'); - }); - installed = true; - } catch { - // Logged by task / caller - } + if (!options.skipInstall && modified) { + installed = await runInstallDependencies(targetDir); } } From a9fdfcfbf6a23ea67185c7ec9576e54b0784c5d5 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 08:39:09 -0400 Subject: [PATCH 03/11] fix: handle plugin upgrade install failures Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.test.ts | 40 +++++++++++++++++----------- src/commands/upgrade/command.ts | 13 +++++---- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index ecc36ca..1972ace 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -17,8 +17,9 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'node:path'; + import { resolveRhdhVersion } from '../../lib/rhdhVersion'; -import * as runMod from '../../lib/run'; +import { Task } from '../../lib/tasks'; import { command, computeTargetVersion, @@ -31,20 +32,13 @@ jest.mock('../../lib/rhdhVersion', () => ({ resolveRhdhVersion: jest.fn(), })); -jest.mock('../../lib/run', () => ({ - ...jest.requireActual('../../lib/run'), - runPlain: jest.fn(), -})); - describe('upgrade command', () => { let tmpDir: string; let originalCwd: string; const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction< typeof resolveRhdhVersion >; - const mockRunPlain = runMod.runPlain as jest.MockedFunction< - typeof runMod.runPlain - >; + let mockTaskForCommand: jest.SpiedFunction; async function setupFixture( pkg: { @@ -110,12 +104,16 @@ describe('upgrade command', () => { 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', () => { @@ -239,18 +237,30 @@ describe('upgrade command', () => { }, }); - mockRunPlain.mockResolvedValue(''); - const result = await upgradePluginDependencies({ targetDir: tmpDir, skipInstall: false, }); expect(result.installed).toBe(true); - expect(mockRunPlain).toHaveBeenCalledWith( - expect.stringMatching(/yarn|npm/), - 'install', - ); + 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(process.exitCode).toBe(1); }); }); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index 1cf1ae5..08a472d 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -19,9 +19,9 @@ import chalk from 'chalk'; import { OptionValues } from 'commander'; import fs from 'fs-extra'; import path from 'node:path'; + import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; -import { runPlain } from '../../lib/run'; import { Task } from '../../lib/tasks'; export type DependencySection = @@ -49,7 +49,7 @@ export interface UpgradePluginOptions { export interface UpgradePluginResult { rhdhVersion: string; backstageVersion: string; - source: 'remote' | 'matrix'; + source: 'remote' | 'matrix' | 'explicit'; changes: PackageUpgradeChange[]; unmanifested: string[]; updatedFiles: string[]; @@ -191,9 +191,7 @@ async function syncBackstageJson( async function runInstallDependencies(targetDir: string): Promise { const pm = await detectPackageManager(targetDir); try { - await Task.forItem('installing', 'dependencies', async () => { - await runPlain(pm, 'install'); - }); + await Task.forCommand(`${pm} install`, { cwd: targetDir }); return true; } catch { return false; @@ -348,6 +346,11 @@ export async function command( process.stderr.write( `\n${chalk.cyan('Dry run completed:')} No files were modified on disk.\n\n`, ); + } else if (!skipInstall && changedCount > 0 && !result.installed) { + Task.error( + 'Dependencies were updated, but installation failed. Resolve the installation error and retry.', + ); + process.exitCode = 1; } else if (result.updatedFiles.length > 0) { const filesStr = chalk.cyan(result.updatedFiles.join(', ')); process.stderr.write( From 4db530cea750e7638f74046718ed771151580329 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 09:23:39 -0400 Subject: [PATCH 04/11] fix: address plugin upgrade review findings Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- README.md | 14 ++++++++ src/commands/check-versions/command.ts | 6 +--- src/commands/index.ts | 4 +++ src/commands/upgrade/command.test.ts | 32 +++++++++++++++--- src/commands/upgrade/command.ts | 47 +++++++++++++------------- src/lib/pluginDependencies.ts | 20 +++++++++++ 6 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 src/lib/pluginDependencies.ts 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/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index 735993a..d9f4a22 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -20,6 +20,7 @@ import fs from 'fs-extra'; import path from 'node:path'; import semver from 'semver'; +import { DependencySection } from '../../lib/pluginDependencies'; import { ExitCodeError } from '../../lib/errors'; import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; @@ -30,11 +31,6 @@ export type DependencyStatus = | 'mismatch' | 'unmanifested' | 'unverifiable'; -export type DependencySection = - | 'dependencies' - | 'devDependencies' - | 'peerDependencies'; - export interface PackageCheckResult { name: string; section: DependencySection; diff --git a/src/commands/index.ts b/src/commands/index.ts index 85096c3..3fbb95c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -169,6 +169,10 @@ export function registerPluginCommand(program: Command) { .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', diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 1972ace..0c6d36a 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -18,6 +18,7 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'node:path'; +import { ExitCodeError } from '../../lib/errors'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; import { Task } from '../../lib/tasks'; import { @@ -75,6 +76,7 @@ describe('upgrade command', () => { 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) => { @@ -90,12 +92,14 @@ describe('upgrade command', () => { try { await command(rhdhVersion, opts); + } catch (caught) { + error = caught as Error; } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); } - return { stdout, stderr }; + return { stdout, stderr, error }; } beforeEach(async () => { @@ -212,12 +216,15 @@ describe('upgrade command', () => { expect(pkg.dependencies['@backstage/core-plugin-api']).toBe('^1.9.0'); }); - it('tracks unmanifested @backstage packages', async () => { + it('deduplicates unmanifested @backstage packages', async () => { await setupFixture( { dependencies: { '@backstage/unknown-pkg': '^1.0.0', }, + peerDependencies: { + '@backstage/unknown-pkg': '^1.0.0', + }, }, [], ); @@ -227,7 +234,7 @@ describe('upgrade command', () => { skipInstall: true, }); - expect(result.unmanifested).toContain('@backstage/unknown-pkg'); + expect(result.unmanifested).toEqual(['@backstage/unknown-pkg']); }); it('runs package manager install unless skipInstall is true', async () => { @@ -260,7 +267,7 @@ describe('upgrade command', () => { expect(res.stderr).toContain('installation failed'); expect(res.stderr).not.toContain('Successfully upgraded'); - expect(process.exitCode).toBe(1); + expect(res.error).toEqual(new ExitCodeError(1)); }); }); @@ -307,5 +314,22 @@ describe('upgrade command', () => { const res = await runCommandWithOutput('2.0.0', { dryRun: true }); expect(res.stderr).toContain('Dry run completed'); }); + + 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 index 08a472d..4f163e9 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -20,15 +20,12 @@ import { OptionValues } from 'commander'; import fs from 'fs-extra'; import path from 'node:path'; +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 type DependencySection = - | 'dependencies' - | 'devDependencies' - | 'peerDependencies'; - export interface PackageUpgradeChange { name: string; section: DependencySection; @@ -42,7 +39,6 @@ export interface UpgradePluginOptions { dryRun?: boolean; skipInstall?: boolean; manifestFile?: string; - json?: boolean; targetDir?: string; } @@ -116,7 +112,7 @@ function applyDependencyUpgrades( ]; const changes: PackageUpgradeChange[] = []; - const unmanifested: string[] = []; + const unmanifested = new Set(); let modified = false; for (const section of sections) { @@ -132,7 +128,7 @@ function applyDependencyUpgrades( } if (!manifestExpected) { - unmanifested.push(name); + unmanifested.add(name); continue; } @@ -157,7 +153,7 @@ function applyDependencyUpgrades( } } - return { changes, unmanifested, modified }; + return { changes, unmanifested: Array.from(unmanifested), modified }; } /** @@ -271,11 +267,17 @@ export async function command( dryRun, skipInstall, manifestFile, - json, }); + 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; } @@ -284,8 +286,17 @@ export async function command( `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 @backstage dependencies found to upgrade.'); + Task.log('No manifest-matched @backstage dependencies found to upgrade.'); return; } @@ -325,7 +336,6 @@ export async function command( process.stderr.write('\n'); - const changedCount = result.changes.filter(c => c.changed).length; const unchangedCount = result.changes.filter(c => !c.changed).length; const updatedStr = chalk.yellow(`↻ ${changedCount} updated`); @@ -333,24 +343,15 @@ export async function command( const summary = `${updatedStr}, ${unchangedStr} (${result.changes.length} total)`; process.stderr.write(`${chalk.bold('Summary:')} ${summary}\n`); - 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 (dryRun) { process.stderr.write( `\n${chalk.cyan('Dry run completed:')} No files were modified on disk.\n\n`, ); - } else if (!skipInstall && changedCount > 0 && !result.installed) { + } else if (installFailed) { Task.error( 'Dependencies were updated, but installation failed. Resolve the installation error and retry.', ); - process.exitCode = 1; + throw new ExitCodeError(1); } else if (result.updatedFiles.length > 0) { const filesStr = chalk.cyan(result.updatedFiles.join(', ')); process.stderr.write( 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'; From 7c8674751b7f062e06baeff35962d31590b06774 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 09:53:53 -0400 Subject: [PATCH 05/11] fix: improve plugin upgrade dry-run handling Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/check-versions/command.ts | 3 +- src/commands/upgrade/command.test.ts | 43 +++++++++++++++++++++ src/commands/upgrade/command.ts | 52 ++++++++++++++++++-------- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index d9f4a22..75f9d3d 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -20,9 +20,9 @@ import fs from 'fs-extra'; import path from 'node:path'; import semver from 'semver'; -import { DependencySection } from '../../lib/pluginDependencies'; 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'; @@ -31,6 +31,7 @@ export type DependencyStatus = | 'mismatch' | 'unmanifested' | 'unverifiable'; + export interface PackageCheckResult { name: string; section: DependencySection; diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 0c6d36a..6eea732 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -212,10 +212,52 @@ describe('upgrade command', () => { }); 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('deduplicates unmanifested @backstage packages', async () => { await setupFixture( { @@ -313,6 +355,7 @@ describe('upgrade command', () => { 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 () => { diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index 4f163e9..1bcf68a 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -49,6 +49,7 @@ export interface UpgradePluginResult { changes: PackageUpgradeChange[]; unmanifested: string[]; updatedFiles: string[]; + wouldUpdateFiles: string[]; installed: boolean; } @@ -162,22 +163,29 @@ function applyDependencyUpgrades( 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 { - const backstageJson = await fs.readJson(backstageJsonPath); - if (backstageJson.version !== targetBackstageVersion) { + backstageJson = await fs.readJson(backstageJsonPath); + } catch { + // Ignore JSON read errors + return undefined; + } + + if (backstageJson.version !== targetBackstageVersion) { + if (!dryRun) { backstageJson.version = targetBackstageVersion; await fs.writeJson(backstageJsonPath, backstageJson, { spaces: 2 }); - return BACKSTAGE_JSON; } - } catch { - // Ignore JSON read errors + return BACKSTAGE_JSON; } + return undefined; } @@ -220,25 +228,33 @@ export async function upgradePluginDependencies( ); const updatedFiles: string[] = []; + const wouldUpdateFiles: string[] = []; let installed = false; - if (!options.dryRun) { - if (modified) { + 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, - ); - if (updatedBsJson) { + const updatedBsJson = await syncBackstageJson( + targetDir, + resolved.backstageVersion, + Boolean(options.dryRun), + ); + if (updatedBsJson) { + if (options.dryRun) { + wouldUpdateFiles.push(updatedBsJson); + } else { updatedFiles.push(updatedBsJson); } + } - if (!options.skipInstall && modified) { - installed = await runInstallDependencies(targetDir); - } + if (!options.dryRun && !options.skipInstall && modified) { + installed = await runInstallDependencies(targetDir); } return { @@ -248,6 +264,7 @@ export async function upgradePluginDependencies( changes, unmanifested, updatedFiles, + wouldUpdateFiles, installed, }; } @@ -344,8 +361,11 @@ export async function command( 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.\n\n`, + `\n${chalk.cyan('Dry run completed:')} No files were modified on disk.${filesStr}\n\n`, ); } else if (installFailed) { Task.error( From 91a1162417878aee89012e211d611b9c8511483d Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 10:06:05 -0400 Subject: [PATCH 06/11] refactor: simplify plugin upgrade command Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.ts | 67 +++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index 1bcf68a..190b5a7 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -269,35 +269,12 @@ export async function upgradePluginDependencies( }; } -/** - * 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; - } - +function printUpgradeResult( + result: UpgradePluginResult, + dryRun: boolean, + installFailed: boolean, + changedCount: number, +): void { const modeLabel = dryRun ? ' (dry run)' : ''; Task.log( `Upgrading plugin dependencies to RHDH v${result.rhdhVersion} (Backstage v${result.backstageVersion}) [${result.source}]${modeLabel}...`, @@ -383,3 +360,35 @@ export async function command( ); } } + +/** + * 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; + } + + printUpgradeResult(result, Boolean(dryRun), installFailed, changedCount); +} From d1fb1131d625b9254b082323750c5aa2db4a437a Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 10:48:27 -0400 Subject: [PATCH 07/11] fix: preserve plugin dependency range specifiers Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.test.ts | 14 +++++++++++++- src/commands/upgrade/command.ts | 21 +++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 6eea732..60c155d 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -15,7 +15,7 @@ */ import fs from 'fs-extra'; -import os from 'os'; +import os from 'node:os'; import path from 'node:path'; import { ExitCodeError } from '../../lib/errors'; @@ -129,6 +129,18 @@ describe('upgrade command', () => { 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', + ); + }); + + 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'); }); diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index 190b5a7..b22ac73 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -19,6 +19,7 @@ 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'; @@ -64,15 +65,18 @@ export function computeTargetVersion( return 'backstage:^'; } - if (currentDeclared.startsWith('^')) { - return `^${manifestExpected}`; + const rangePrefix = currentDeclared.match( + /^(?:workspace:)?(?:\^|~|>=|<=|>|<|=)/, + )?.[0]; + if (rangePrefix) { + return `${rangePrefix}${manifestExpected}`; } - if (currentDeclared.startsWith('~')) { - return `~${manifestExpected}`; + if (semver.valid(currentDeclared)) { + return manifestExpected; } - return manifestExpected; + return currentDeclared; } /** @@ -269,6 +273,9 @@ export async function upgradePluginDependencies( }; } +/** + * Renders human-readable results for a plugin dependency upgrade. + */ function printUpgradeResult( result: UpgradePluginResult, dryRun: boolean, @@ -315,9 +322,7 @@ function printUpgradeResult( 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 + ' Status'.length))}\n`, - ); + process.stderr.write(`${chalk.gray('-'.repeat(header.length))}\n`); for (const change of result.changes) { const statusLabel = change.changed From 41bc355f8587388824849c49f5e7a787ebdbff32 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 10:58:30 -0400 Subject: [PATCH 08/11] refactor: use regex exec for dependency ranges Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index b22ac73..a4b78c5 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -65,8 +65,8 @@ export function computeTargetVersion( return 'backstage:^'; } - const rangePrefix = currentDeclared.match( - /^(?:workspace:)?(?:\^|~|>=|<=|>|<|=)/, + const rangePrefix = /^(?:workspace:)?(?:\^|~|>=|<=|>|<|=)/.exec( + currentDeclared, )?.[0]; if (rangePrefix) { return `${rangePrefix}${manifestExpected}`; From b9980507d15c9b7ce1a2cef1af04a83ed48ab5f6 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 10 Sep 2026 12:01:38 -0400 Subject: [PATCH 09/11] fix: support exact workspace dependency versions Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.test.ts | 5 ++++- src/commands/upgrade/command.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 60c155d..094548f 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -121,7 +121,7 @@ describe('upgrade command', () => { }); describe('computeTargetVersion', () => { - it('preserves carat prefix', () => { + it('preserves caret prefix', () => { expect(computeTargetVersion('^1.10.0', '1.12.0')).toBe('^1.12.0'); }); @@ -134,6 +134,9 @@ describe('upgrade command', () => { 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 unsupported ranges unchanged', () => { diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index a4b78c5..cf3a71d 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -65,9 +65,18 @@ export function computeTargetVersion( return 'backstage:^'; } - const rangePrefix = /^(?:workspace:)?(?:\^|~|>=|<=|>|<|=)/.exec( - currentDeclared, - )?.[0]; + 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) { return `${rangePrefix}${manifestExpected}`; } From eb67054b6991f877f8ac034ab1f51ee0735f8adf Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 11 Sep 2026 07:56:30 -0400 Subject: [PATCH 10/11] chore: bump version to 2.0.6 and update changelog Assisted-By: openai/gpt-5.6-terra Signed-off-by: Stan Lewis rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- CHANGELOG.md | 7 ++++++- package.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c49c998..494f632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,16 @@ 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.5 - 2026-09-04 +## 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 + - **`plugin check-versions`:** Add `rhdh-cli plugin check-versions` (alias `plugin versions:lint`) command and RHDH-to-Backstage version mapping engine ([RHIDP-16665](https://redhat.atlassian.net/browse/RHIDP-16665), [RHIDP-16667](https://redhat.atlassian.net/browse/RHIDP-16667), [#176](https://github.com/redhat-developer/rhdh-cli/pull/176)). Supports auditing `@backstage/*` dependencies in `package.json` against target RHDH release manifests using a 3-tier resolution engine (remote GitHub build-metadata, embedded static compatibility matrix fallback, and Backstage release manifests). ## 2.0.4 - 2026-08-27 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" }, From fd1c54c37a2eb490be1ca991cc42e81cf0905a48 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 11 Sep 2026 13:31:05 -0400 Subject: [PATCH 11/11] fix: address plugin upgrade review findings Assisted-By: OpenCode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/upgrade/command.test.ts | 23 +++++++++++++++++++ src/commands/upgrade/command.ts | 33 ++++++++++++++++++---------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/commands/upgrade/command.test.ts b/src/commands/upgrade/command.test.ts index 094548f..0d3cd62 100644 --- a/src/commands/upgrade/command.test.ts +++ b/src/commands/upgrade/command.test.ts @@ -139,6 +139,12 @@ describe('upgrade command', () => { ); }); + 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:*'); @@ -273,6 +279,23 @@ describe('upgrade command', () => { 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( { diff --git a/src/commands/upgrade/command.ts b/src/commands/upgrade/command.ts index cf3a71d..4abf506 100644 --- a/src/commands/upgrade/command.ts +++ b/src/commands/upgrade/command.ts @@ -35,7 +35,7 @@ export interface PackageUpgradeChange { changed: boolean; } -export interface UpgradePluginOptions { +export interface UpgradeOptions { rhdhVersion?: string; dryRun?: boolean; skipInstall?: boolean; @@ -43,7 +43,7 @@ export interface UpgradePluginOptions { targetDir?: string; } -export interface UpgradePluginResult { +export interface UpgradeResult { rhdhVersion: string; backstageVersion: string; source: 'remote' | 'matrix' | 'explicit'; @@ -78,7 +78,10 @@ export function computeTargetVersion( const rangePrefix = /^(?:\^|~|>=|<=|>|<|=)/.exec(currentDeclared)?.[0]; if (rangePrefix) { - return `${rangePrefix}${manifestExpected}`; + const declaredVersion = currentDeclared.slice(rangePrefix.length); + if (semver.valid(declaredVersion)) { + return `${rangePrefix}${manifestExpected}`; + } } if (semver.valid(currentDeclared)) { @@ -187,7 +190,9 @@ async function syncBackstageJson( try { backstageJson = await fs.readJson(backstageJsonPath); } catch { - // Ignore JSON read errors + process.stderr.write( + `${chalk.yellow('Warning:')} Could not parse ${BACKSTAGE_JSON}; skipping its version update.\n`, + ); return undefined; } @@ -219,8 +224,8 @@ async function runInstallDependencies(targetDir: string): Promise { * Upgrades @backstage/* dependencies in a package.json to match target RHDH release manifest */ export async function upgradePluginDependencies( - options: UpgradePluginOptions = {}, -): Promise { + options: UpgradeOptions = {}, +): Promise { const targetDir = options.targetDir || paths.targetDir; const packageJsonPath = path.join(targetDir, 'package.json'); @@ -286,11 +291,11 @@ export async function upgradePluginDependencies( * Renders human-readable results for a plugin dependency upgrade. */ function printUpgradeResult( - result: UpgradePluginResult, + result: UpgradeResult, dryRun: boolean, installFailed: boolean, changedCount: number, -): void { +): boolean { const modeLabel = dryRun ? ' (dry run)' : ''; Task.log( `Upgrading plugin dependencies to RHDH v${result.rhdhVersion} (Backstage v${result.backstageVersion}) [${result.source}]${modeLabel}...`, @@ -307,7 +312,7 @@ function printUpgradeResult( if (result.changes.length === 0) { Task.log('No manifest-matched @backstage dependencies found to upgrade.'); - return; + return false; } process.stderr.write('\n'); @@ -362,7 +367,7 @@ function printUpgradeResult( Task.error( 'Dependencies were updated, but installation failed. Resolve the installation error and retry.', ); - throw new ExitCodeError(1); + return true; } else if (result.updatedFiles.length > 0) { const filesStr = chalk.cyan(result.updatedFiles.join(', ')); process.stderr.write( @@ -373,6 +378,8 @@ function printUpgradeResult( `\n${chalk.green('✔ All dependencies are already up to date.')}\n\n`, ); } + + return false; } /** @@ -404,5 +411,9 @@ export async function command( return; } - printUpgradeResult(result, Boolean(dryRun), installFailed, changedCount); + if ( + printUpgradeResult(result, Boolean(dryRun), installFailed, changedCount) + ) { + throw new ExitCodeError(1); + } }