Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.5 - 2026-09-04
Comment thread
gashcrumb marked this conversation as resolved.

### 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

### Added
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ On Windows, use Git Bash or WSL so these tools are available.

When you build an OCI image with `--tag` (instead of exporting to a directory with `--export-to`), a container build tool must also be on `PATH`. **podman** is the default; you can select **docker** or **buildah** with `--container-tool` (for example `--container-tool docker`). Directory-only exports with `--export-to` do not need a container tool.

## Checking Plugin Versions

Use `plugin check-versions` to compare a plugin's `@backstage/*` dependencies with the Backstage release used by an RHDH version:

```bash
rhdh-cli plugin check-versions --rhdh-version 2.0.0
```

Use `--json` for machine-readable output. To target a Backstage version directly, prefix it with `backstage:`, for example `--rhdh-version backstage:1.54.0`.

For air-gapped environments, provide a local release manifest with `--manifest-file`. `--manifest-file` avoids the Backstage manifest download; also set `RHDH_OFFLINE=true` to skip the RHDH GitHub metadata lookup.

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.

## Development

### Contributing
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@red-hat-developer-hub/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "2.0.4",
"version": "2.0.5",
"publishConfig": {
"access": "public"
},
Expand Down
231 changes: 231 additions & 0 deletions src/commands/check-versions/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
* 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 { ExitCodeError } from '../../lib/errors';
import { resolveRhdhVersion } from '../../lib/rhdhVersion';
import { checkPluginDependencies, command } from './command';

jest.mock('../../lib/rhdhVersion', () => ({
...jest.requireActual('../../lib/rhdhVersion'),
resolveRhdhVersion: jest.fn(),
}));

describe('checkPluginDependencies', () => {
let tmpDir: string;
let originalCwd: string;
const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction<
typeof resolveRhdhVersion
>;

async function setupFixture(
pkg: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
},
manifestPackages: [string, string][] = [
['@backstage/core-plugin-api', '1.12.0'],
],
) {
await fs.writeJson(path.join(tmpDir, 'package.json'), {
name: 'test-plugin',
...pkg,
});
mockResolveRhdhVersion.mockResolvedValue({
rhdhVersion: '2.0.0',
backstageVersion: '1.52.0',
source: 'matrix',
packages: new Map(manifestPackages),
});
}

async function runCommandWithOutput(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(opts);
} catch (caughtError) {
error = caughtError as Error;
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}

return { stdout, stderr, error };
}

beforeEach(async () => {
originalCwd = process.cwd();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'check-versions-test-'));
process.chdir(tmpDir);
process.exitCode = undefined;
jest.clearAllMocks();
});

afterEach(async () => {
process.chdir(originalCwd);
await fs.remove(tmpDir);
process.exitCode = undefined;
});

it('throws error when package.json does not exist', async () => {
await expect(
checkPluginDependencies({ targetDir: tmpDir }),
).rejects.toThrow(/No package\.json found/);
});

it('reports matching dependencies when versions align with manifest', async () => {
await setupFixture(
{
dependencies: {
'@backstage/core-plugin-api': '^1.12.0',
'@backstage/catalog-model': '~1.7.6',
},
devDependencies: {
'@backstage/cli': '0.36.3',
},
peerDependencies: {
'@backstage/config': 'backstage:^',
},
},
[
['@backstage/core-plugin-api', '1.12.0'],
['@backstage/catalog-model', '1.7.6'],
['@backstage/cli', '0.36.3'],
['@backstage/config', '1.3.8'],
],
);

const result = await checkPluginDependencies({ targetDir: tmpDir });

expect(result.valid).toBe(true);
expect(result.counts.matching).toBe(3);
expect(result.counts.mismatched).toBe(0);
expect(result.counts.unmanifested).toBe(0);
expect(result.counts.unverifiable).toBe(1);
expect(
result.packages.find(p => p.name === '@backstage/config')?.status,
).toBe('unverifiable');
});

it('reports mismatched and unmanifested dependencies when versions differ', async () => {
await setupFixture(
{
dependencies: {
'@backstage/core-plugin-api': '^1.9.0',
'@backstage/unknown-pkg': '^1.0.0',
lodash: '^4.17.21',
},
devDependencies: {
'@backstage/cli': '^0.30.0',
},
},
[
['@backstage/core-plugin-api', '1.12.0'],
['@backstage/cli', '0.36.3'],
],
);

const result = await checkPluginDependencies({ targetDir: tmpDir });

expect(result.valid).toBe(false);
expect(result.counts.matching).toBe(0);
expect(result.counts.mismatched).toBe(2);
expect(result.counts.unmanifested).toBe(1);
expect(result.counts.unverifiable).toBe(0);
expect(result.counts.total).toBe(3);

const corePluginApi = result.packages.find(
p => p.name === '@backstage/core-plugin-api',
);
expect(corePluginApi?.status).toBe('mismatch');
expect(corePluginApi?.declared).toBe('^1.9.0');
expect(corePluginApi?.expected).toBe('1.12.0');

const unknownPkg = result.packages.find(
p => p.name === '@backstage/unknown-pkg',
);
expect(unknownPkg?.status).toBe('unmanifested');
expect(unknownPkg?.expected).toBeUndefined();
});

describe('CLI command handler', () => {
it('reports backstage:^ dependencies as unverifiable', async () => {
await setupFixture(
{ peerDependencies: { '@backstage/config': 'backstage:^' } },
[['@backstage/config', '1.3.8']],
);

const res = await runCommandWithOutput({});
expect(res.stderr).toContain('cannot verify backstage:^');
expect(res.stderr).toContain('cannot be verified');
expect(res.error).toBeUndefined();
});

it('outputs JSON when --json flag is passed and sets exitCode on failure', async () => {
await setupFixture({
dependencies: { '@backstage/core-plugin-api': '^1.9.0' },
});

const res = await runCommandWithOutput({ json: true });
const parsed = JSON.parse(res.stdout);
expect(parsed.valid).toBe(false);
expect(parsed.counts.mismatched).toBe(1);
expect(res.error).toEqual(new ExitCodeError(1));
});

it('prints formatted table and remediation when run in human mode', async () => {
await setupFixture({
dependencies: { '@backstage/core-plugin-api': '^1.9.0' },
});

const res = await runCommandWithOutput({});
expect(res.stderr).toContain('Package');
expect(res.stderr).toContain('@backstage/core-plugin-api');
expect(res.stderr).toContain('mismatch');
expect(res.stderr).toContain('rhdh-cli plugin upgrade 2.0.0');
expect(res.error).toEqual(new ExitCodeError(1));
});

it('prints success message when dependencies are aligned', async () => {
await setupFixture({
dependencies: { '@backstage/core-plugin-api': '^1.12.0' },
});

const res = await runCommandWithOutput({});
expect(res.stderr).toContain('All @backstage dependencies are aligned');
expect(res.error).toBeUndefined();
});
});
});
Loading
Loading