From 6bb43a90ad92aab4cc85201de110527ee0cdae25 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 11 Sep 2026 07:59:59 -0400 Subject: [PATCH 1/7] feat: add plugin new 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 --- .prettierignore | 1 + README.md | 10 + src/commands/index.ts | 15 ++ src/commands/new/command.test.ts | 142 ++++++++++++++ src/commands/new/command.ts | 177 ++++++++++++++++++ src/commands/new/index.ts | 1 + src/lib/rhdhVersion.test.ts | 2 +- src/lib/rhdhVersion.ts | 6 +- .../plugin-new/backend-module/.yarnrc.yml | 1 + .../plugin-new/backend-module/README.md.hbs | 22 +++ .../backend-module/backstage.json.hbs | 1 + .../backend-module/package.json.hbs | 30 +++ .../backend-module/src/index.ts.hbs | 24 +++ .../plugin-new/backend-module/tsconfig.json | 8 + templates/plugin-new/backend/.yarnrc.yml | 1 + templates/plugin-new/backend/README.md.hbs | 22 +++ .../plugin-new/backend/backstage.json.hbs | 1 + templates/plugin-new/backend/package.json.hbs | 22 +++ templates/plugin-new/backend/src/index.ts.hbs | 8 + templates/plugin-new/backend/tsconfig.json | 8 + templates/plugin-new/frontend/.yarnrc.yml | 1 + templates/plugin-new/frontend/README.md.hbs | 22 +++ .../plugin-new/frontend/backstage.json.hbs | 1 + .../plugin-new/frontend/package.json.hbs | 34 ++++ .../frontend/src/PluginPage.tsx.hbs | 5 + .../plugin-new/frontend/src/index.ts.hbs | 19 ++ templates/plugin-new/frontend/tsconfig.json | 8 + 27 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/commands/new/command.test.ts create mode 100644 src/commands/new/command.ts create mode 100644 src/commands/new/index.ts create mode 100644 templates/plugin-new/backend-module/.yarnrc.yml create mode 100644 templates/plugin-new/backend-module/README.md.hbs create mode 100644 templates/plugin-new/backend-module/backstage.json.hbs create mode 100644 templates/plugin-new/backend-module/package.json.hbs create mode 100644 templates/plugin-new/backend-module/src/index.ts.hbs create mode 100644 templates/plugin-new/backend-module/tsconfig.json create mode 100644 templates/plugin-new/backend/.yarnrc.yml create mode 100644 templates/plugin-new/backend/README.md.hbs create mode 100644 templates/plugin-new/backend/backstage.json.hbs create mode 100644 templates/plugin-new/backend/package.json.hbs create mode 100644 templates/plugin-new/backend/src/index.ts.hbs create mode 100644 templates/plugin-new/backend/tsconfig.json create mode 100644 templates/plugin-new/frontend/.yarnrc.yml create mode 100644 templates/plugin-new/frontend/README.md.hbs create mode 100644 templates/plugin-new/frontend/backstage.json.hbs create mode 100644 templates/plugin-new/frontend/package.json.hbs create mode 100644 templates/plugin-new/frontend/src/PluginPage.tsx.hbs create mode 100644 templates/plugin-new/frontend/src/index.ts.hbs create mode 100644 templates/plugin-new/frontend/tsconfig.json diff --git a/.prettierignore b/.prettierignore index 3cf6d82..2aa44b5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ coverage .vscode .eslintrc.js .yarn +templates/plugin-new diff --git a/README.md b/README.md index 1527ac6..ed7fd97 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ Use `--dry-run` to preview dependency changes without writing files and `--skip- 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. +## Creating a Plugin + +Use `plugin new` to create a standalone, version-pinned dynamic plugin project: + +```bash +rhdh-cli plugin new my-plugin --type frontend --rhdh-version 2.1.0 +``` + +Supported types are `frontend` (an NFS page), `backend` (a minimal new-backend-system plugin), and `backend-module` (a catalog processor module). Use `--output ` to select a destination. The generated project uses the target RHDH release's Backstage manifest for every `@backstage/*` dependency. Export and package generated plugins with `npx @red-hat-developer-hub/cli`, or through RHDH Dynamic Plugin Factory, rather than adding the CLI as a project dependency. + ## Development ### Contributing diff --git a/src/commands/index.ts b/src/commands/index.ts index 3fbb95c..b717638 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -187,6 +187,21 @@ export function registerPluginCommand(program: Command) { ) .option('--json', 'Output upgrade results as JSON') .action(lazy(() => import('./upgrade').then(m => m.command))); + command + .command('new [name]') + .description('Create a standalone RHDH dynamic plugin project') + .option('--name ', 'Plugin name (alternative to the argument)') + .option('--type ', 'Plugin type to create') + .option('--rhdh-version ', 'Target RHDH version') + .option( + '--output ', + 'Output directory (defaults to the plugin name)', + ) + .option( + '--manifest-file ', + 'Path to a local Backstage release manifest JSON file', + ) + .action(lazy(() => import('./new').then(m => m.command))); } export function registerCommands(program: Command) { diff --git a/src/commands/new/command.test.ts b/src/commands/new/command.test.ts new file mode 100644 index 0000000..87f6671 --- /dev/null +++ b/src/commands/new/command.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { completeInteractiveOptions, createPluginProject } from './command'; + +jest.mock('../../lib/rhdhVersion', () => ({ + ...jest.requireActual('../../lib/rhdhVersion'), + resolveRhdhVersion: jest.fn(), +})); + +describe('createPluginProject', () => { + let tmpDir: string; + const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction< + typeof resolveRhdhVersion + >; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-new-test-')); + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.1.0', + backstageVersion: '1.54.0', + source: 'matrix', + packages: new Map([ + ['@backstage/backend-plugin-api', '1.10.0'], + ['@backstage/catalog-model', '1.10.0'], + ['@backstage/cli', '0.36.5'], + ['@backstage/core-plugin-api', '1.12.7'], + ['@backstage/frontend-plugin-api', '0.17.2'], + ['@backstage/plugin-catalog-node', '2.2.4'], + ]), + }); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + jest.clearAllMocks(); + }); + + it.each([ + ['frontend', 'src/index.ts', 'PageBlueprint'], + ['backend', 'src/index.ts', 'createBackendPlugin'], + ['backend-module', 'src/index.ts', 'catalogProcessingExtensionPoint'], + ])( + 'creates a %s plugin project', + async (type, entryPoint, expectedSource) => { + const output = path.join(tmpDir, type); + + const result = await createPluginProject({ + name: 'example-plugin', + type, + output, + rhdhVersion: '2.1.0', + }); + + expect(result).toEqual({ + outputDir: output, + rhdhVersion: '2.1.0', + backstageVersion: '1.54.0', + }); + expect(mockResolveRhdhVersion).toHaveBeenCalledWith('2.1.0', { + manifestFile: undefined, + }); + expect( + await fs.readFile(path.join(output, entryPoint), 'utf8'), + ).toContain(expectedSource); + + const packageJson = await fs.readJson(path.join(output, 'package.json')); + expect(packageJson.devDependencies['@backstage/cli']).toBe('0.36.5'); + expect(packageJson.devDependencies).not.toHaveProperty( + '@red-hat-developer-hub/cli', + ); + expect(packageJson.packageManager).toBe('yarn@4.17.1'); + await expect( + fs.readFile(path.join(output, '.yarnrc.yml'), 'utf8'), + ).resolves.toBe('nodeLinker: node-modules\n'); + await expect( + fs.readFile(path.join(output, 'README.md'), 'utf8'), + ).resolves.toContain('npx @red-hat-developer-hub/cli plugin export'); + await expect( + fs.readJson(path.join(output, 'backstage.json')), + ).resolves.toEqual({ version: '1.54.0' }); + }, + ); + + it('rejects an invalid name before resolving a version', async () => { + await expect( + createPluginProject({ + name: 'Example', + type: 'frontend', + output: tmpDir, + }), + ).rejects.toThrow('Plugin name must start'); + expect(mockResolveRhdhVersion).not.toHaveBeenCalled(); + }); + + it('rejects a non-empty output directory', async () => { + await fs.outputFile(path.join(tmpDir, 'existing'), 'content'); + + await expect( + createPluginProject({ + name: 'example', + type: 'frontend', + output: tmpDir, + }), + ).rejects.toThrow('is not empty'); + expect(mockResolveRhdhVersion).not.toHaveBeenCalled(); + }); + + it('prompts only for missing name and plugin type', async () => { + const prompt = jest + .fn, [string]>() + .mockResolvedValueOnce('example-plugin') + .mockResolvedValueOnce('frontend'); + + await expect( + completeInteractiveOptions({ rhdhVersion: '2.1.0' }, prompt), + ).resolves.toEqual({ + name: 'example-plugin', + type: 'frontend', + rhdhVersion: '2.1.0', + }); + expect(prompt).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/commands/new/command.ts b/src/commands/new/command.ts new file mode 100644 index 0000000..eead048 --- /dev/null +++ b/src/commands/new/command.ts @@ -0,0 +1,177 @@ +/* + * 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 { OptionValues } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; + +import { paths } from '../../lib/paths'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { templatingTask } from '../../lib/tasks'; + +export const pluginTypes = ['frontend', 'backend', 'backend-module'] as const; +export type PluginType = (typeof pluginTypes)[number]; + +export interface CreatePluginOptions { + name?: string; + type?: string; + output?: string; + rhdhVersion?: string; + manifestFile?: string; +} + +type Prompt = (question: string) => Promise; + +function assertPluginName(name: string): void { + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new Error( + 'Plugin name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.', + ); + } +} + +function assertPluginType( + type: string | undefined, +): asserts type is PluginType { + if (!type || !pluginTypes.includes(type as PluginType)) { + throw new Error(`Plugin type must be one of: ${pluginTypes.join(', ')}.`); + } +} + +export async function completeInteractiveOptions( + options: CreatePluginOptions, + prompt: Prompt, +): Promise { + return { + ...options, + name: options.name || (await prompt('Plugin name: ')).trim(), + type: + options.type || + ( + await prompt('Plugin type (frontend, backend, backend-module): ') + ).trim(), + }; +} + +async function promptForMissingOptions( + options: CreatePluginOptions, +): Promise { + if (options.name && options.type) { + return options; + } + if (!process.stdin.isTTY) { + throw new Error( + 'Plugin name and --type are required when running without an interactive terminal.', + ); + } + + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + return await completeInteractiveOptions(options, question => + readline.question(question), + ); + } finally { + readline.close(); + } +} + +/** Creates a standalone plugin project from a version-pinned template. */ +export async function createPluginProject( + options: CreatePluginOptions, +): Promise<{ + outputDir: string; + rhdhVersion: string; + backstageVersion: string; +}> { + if (!options.name) { + throw new Error( + 'Plugin name is required. Pass it as an argument or with --name.', + ); + } + assertPluginName(options.name); + assertPluginType(options.type); + + const outputDir = path.resolve(options.output || options.name); + if (await fs.pathExists(outputDir)) { + const contents = await fs.readdir(outputDir); + if (contents.length > 0) { + throw new Error(`Output directory "${outputDir}" is not empty.`); + } + } + + const resolved = await resolveRhdhVersion(options.rhdhVersion, { + manifestFile: options.manifestFile, + }); + const versionProvider = (packageName: string): string => { + const version = resolved.packages.get(packageName); + if (!version) { + throw new Error( + `The Backstage ${resolved.backstageVersion} release manifest does not contain ${packageName}.`, + ); + } + return version; + }; + const templateDir = paths.resolveOwn(`templates/plugin-new/${options.type}`); + + await fs.ensureDir(outputDir); + await templatingTask( + templateDir, + outputDir, + { + pluginId: options.name, + packageName: `@internal/backstage-plugin-${options.name}`, + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + }, + versionProvider, + false, + ); + + return { + outputDir, + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + }; +} + +/** CLI command entry point for `rhdh-cli plugin new`. */ +export async function command( + name: string | undefined, + opts: OptionValues, +): Promise { + if (name && opts.name && name !== opts.name) { + throw new Error( + 'Plugin name argument and --name must match when both are provided.', + ); + } + + const options = await promptForMissingOptions({ + name: name || opts.name, + type: opts.type, + output: opts.output, + rhdhVersion: opts.rhdhVersion, + manifestFile: opts.manifestFile, + }); + const result = await createPluginProject(options); + + process.stdout.write( + `Created plugin in ${result.outputDir} for RHDH ${result.rhdhVersion} (Backstage ${result.backstageVersion}).\n`, + ); +} diff --git a/src/commands/new/index.ts b/src/commands/new/index.ts new file mode 100644 index 0000000..2588449 --- /dev/null +++ b/src/commands/new/index.ts @@ -0,0 +1 @@ +export { command } from './command'; diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index de56319..e6570cc 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -151,7 +151,7 @@ describe('rhdhVersion', () => { expect(findStaticMatrixBackstageVersion('2.0.0')).toBe('1.52.0'); expect(findStaticMatrixBackstageVersion('1.9.0')).toBe('1.45.3'); expect(findStaticMatrixBackstageVersion('1.8.0')).toBe('1.42.5'); - expect(findStaticMatrixBackstageVersion('main')).toBe('1.54.0'); + expect(findStaticMatrixBackstageVersion('main')).toBe('1.54.6'); }); it('resolves minor versions without patch to matrix entry', () => { diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index 35aaa10..fd5ec6f 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -26,7 +26,7 @@ import { * Used for offline operations and as a fallback when remote metadata lookup is unavailable. */ export const RHDH_COMPATIBILITY_MATRIX: Record = { - '2.1.0': '1.54.0', + '2.1.0': '1.54.6', '2.0.4': '1.52.0', '2.0.0': '1.52.0', '1.10.0': '1.49.4', @@ -34,8 +34,8 @@ export const RHDH_COMPATIBILITY_MATRIX: Record = { '1.8.0': '1.42.5', '1.7.0': '1.39.1', '1.6.0': '1.36.1', - main: '1.54.0', - next: '1.54.0', + main: '1.54.6', + next: '1.54.6', }; /** diff --git a/templates/plugin-new/backend-module/.yarnrc.yml b/templates/plugin-new/backend-module/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/templates/plugin-new/backend-module/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/templates/plugin-new/backend-module/README.md.hbs b/templates/plugin-new/backend-module/README.md.hbs new file mode 100644 index 0000000..935027a --- /dev/null +++ b/templates/plugin-new/backend-module/README.md.hbs @@ -0,0 +1,22 @@ +# {{pluginId}} + +An RHDH dynamic backend module that registers a catalog processor. + +## Development + +```bash +yarn install +yarn build +yarn test +``` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +```bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +``` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. diff --git a/templates/plugin-new/backend-module/backstage.json.hbs b/templates/plugin-new/backend-module/backstage.json.hbs new file mode 100644 index 0000000..0ba5066 --- /dev/null +++ b/templates/plugin-new/backend-module/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } \ No newline at end of file diff --git a/templates/plugin-new/backend-module/package.json.hbs b/templates/plugin-new/backend-module/package.json.hbs new file mode 100644 index 0000000..3673214 --- /dev/null +++ b/templates/plugin-new/backend-module/package.json.hbs @@ -0,0 +1,30 @@ +{ + "name": "{{packageName}}-catalog-backend-module", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { + "@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}", + "@backstage/catalog-model": "{{versionQuery '@backstage/catalog-model'}}", + "@backstage/plugin-catalog-node": "{{versionQuery '@backstage/plugin-catalog-node'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.4.5" + } +} diff --git a/templates/plugin-new/backend-module/src/index.ts.hbs b/templates/plugin-new/backend-module/src/index.ts.hbs new file mode 100644 index 0000000..9046d4b --- /dev/null +++ b/templates/plugin-new/backend-module/src/index.ts.hbs @@ -0,0 +1,24 @@ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + catalogProcessingExtensionPoint, + type CatalogProcessor, +} from '@backstage/plugin-catalog-node'; + +const processor: CatalogProcessor = { + getProcessorName: () => '{{pluginId}}Processor', +}; + +const module = createBackendModule({ + pluginId: 'catalog', + moduleId: '{{pluginId}}', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(processor); + }, + }); + }, +}); + +export default module; diff --git a/templates/plugin-new/backend-module/tsconfig.json b/templates/plugin-new/backend-module/tsconfig.json new file mode 100644 index 0000000..7e1995b --- /dev/null +++ b/templates/plugin-new/backend-module/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/templates/plugin-new/backend/.yarnrc.yml b/templates/plugin-new/backend/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/templates/plugin-new/backend/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/templates/plugin-new/backend/README.md.hbs b/templates/plugin-new/backend/README.md.hbs new file mode 100644 index 0000000..6935fe6 --- /dev/null +++ b/templates/plugin-new/backend/README.md.hbs @@ -0,0 +1,22 @@ +# {{pluginId}} + +An RHDH dynamic backend plugin using the New Backend System. + +## Development + +```bash +yarn install +yarn build +yarn test +``` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +```bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +``` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. diff --git a/templates/plugin-new/backend/backstage.json.hbs b/templates/plugin-new/backend/backstage.json.hbs new file mode 100644 index 0000000..0ba5066 --- /dev/null +++ b/templates/plugin-new/backend/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } \ No newline at end of file diff --git a/templates/plugin-new/backend/package.json.hbs b/templates/plugin-new/backend/package.json.hbs new file mode 100644 index 0000000..4f5b591 --- /dev/null +++ b/templates/plugin-new/backend/package.json.hbs @@ -0,0 +1,22 @@ +{ + "name": "{{packageName}}-backend", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { "role": "backend-plugin", "pluginId": "{{pluginId}}" }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { "@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}" }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.4.5" + } +} diff --git a/templates/plugin-new/backend/src/index.ts.hbs b/templates/plugin-new/backend/src/index.ts.hbs new file mode 100644 index 0000000..1a0b3aa --- /dev/null +++ b/templates/plugin-new/backend/src/index.ts.hbs @@ -0,0 +1,8 @@ +import { createBackendPlugin } from '@backstage/backend-plugin-api'; + +const plugin = createBackendPlugin({ + pluginId: '{{pluginId}}', + register() {}, +}); + +export default plugin; diff --git a/templates/plugin-new/backend/tsconfig.json b/templates/plugin-new/backend/tsconfig.json new file mode 100644 index 0000000..7e1995b --- /dev/null +++ b/templates/plugin-new/backend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/templates/plugin-new/frontend/.yarnrc.yml b/templates/plugin-new/frontend/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/templates/plugin-new/frontend/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/templates/plugin-new/frontend/README.md.hbs b/templates/plugin-new/frontend/README.md.hbs new file mode 100644 index 0000000..f87bb2d --- /dev/null +++ b/templates/plugin-new/frontend/README.md.hbs @@ -0,0 +1,22 @@ +# {{pluginId}} + +An RHDH dynamic frontend plugin using the New Frontend System. + +## Development + +```bash +yarn install +yarn build +yarn test +``` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +```bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +``` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. diff --git a/templates/plugin-new/frontend/backstage.json.hbs b/templates/plugin-new/frontend/backstage.json.hbs new file mode 100644 index 0000000..0ba5066 --- /dev/null +++ b/templates/plugin-new/frontend/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } \ No newline at end of file diff --git a/templates/plugin-new/frontend/package.json.hbs b/templates/plugin-new/frontend/package.json.hbs new file mode 100644 index 0000000..c287720 --- /dev/null +++ b/templates/plugin-new/frontend/package.json.hbs @@ -0,0 +1,34 @@ +{ + "name": "{{packageName}}", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { "role": "frontend-plugin", "pluginId": "{{pluginId}}" }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { + "@backstage/core-plugin-api": "{{versionQuery '@backstage/core-plugin-api'}}", + "@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/jest": "^29.5.14", + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.30.2", + "jest": "^29.7.0", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.30.2" + } +} diff --git a/templates/plugin-new/frontend/src/PluginPage.tsx.hbs b/templates/plugin-new/frontend/src/PluginPage.tsx.hbs new file mode 100644 index 0000000..ad388ee --- /dev/null +++ b/templates/plugin-new/frontend/src/PluginPage.tsx.hbs @@ -0,0 +1,5 @@ +import React from 'react'; + +export function PluginPage() { + return React.createElement('h1', undefined, '{{pluginId}}'); +} diff --git a/templates/plugin-new/frontend/src/index.ts.hbs b/templates/plugin-new/frontend/src/index.ts.hbs new file mode 100644 index 0000000..be474c2 --- /dev/null +++ b/templates/plugin-new/frontend/src/index.ts.hbs @@ -0,0 +1,19 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; +import { createFrontendPlugin, PageBlueprint } from '@backstage/frontend-plugin-api'; +import React from 'react'; + +const rootRouteRef = createRouteRef({ id: '{{pluginId}}' }); +const page = PageBlueprint.make({ + params: { + title: '{{pluginId}}', + path: '/{{pluginId}}', + routeRef: rootRouteRef, + loader: () => import('./PluginPage').then(m => React.createElement(m.PluginPage)), + }, +}); + +export default createFrontendPlugin({ + pluginId: '{{pluginId}}', + extensions: [page], + routes: { root: rootRouteRef }, +}); diff --git a/templates/plugin-new/frontend/tsconfig.json b/templates/plugin-new/frontend/tsconfig.json new file mode 100644 index 0000000..7e1995b --- /dev/null +++ b/templates/plugin-new/frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} From 4c3cbdba13a51c65e32c445b070a2c7773a1df01 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 11 Sep 2026 08:26:17 -0400 Subject: [PATCH 2/7] fix: address plugin new 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 --- CHANGELOG.md | 6 ++ README.md | 2 +- src/commands/new/command.ts | 75 +++++++++++-------- src/lib/rhdhVersion.ts | 6 +- .../backend-module/src/index.ts.hbs | 4 +- 5 files changed, 58 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 494f632..64440fc 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). +## Unreleased + +### Fixed + +- Update the RHDH 2.1.0, `main`, and `next` compatibility mappings to Backstage 1.54.6. + ## 2.0.6 - 2026-09-11 ### Added diff --git a/README.md b/README.md index ed7fd97..e9bfa27 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Use `plugin new` to create a standalone, version-pinned dynamic plugin project: rhdh-cli plugin new my-plugin --type frontend --rhdh-version 2.1.0 ``` -Supported types are `frontend` (an NFS page), `backend` (a minimal new-backend-system plugin), and `backend-module` (a catalog processor module). Use `--output ` to select a destination. The generated project uses the target RHDH release's Backstage manifest for every `@backstage/*` dependency. Export and package generated plugins with `npx @red-hat-developer-hub/cli`, or through RHDH Dynamic Plugin Factory, rather than adding the CLI as a project dependency. +Supported types are `frontend` (a New Frontend System, or NFS, page), `backend` (a minimal new-backend-system plugin), and `backend-module` (a catalog processor module). Use `--name ` as an alternative to the positional name, and `--output ` to select a destination. The generated project uses the target RHDH release's Backstage manifest for every `@backstage/*` dependency. For air-gapped environments, provide `--manifest-file` and set `RHDH_OFFLINE=true`. Export and package generated plugins with `npx @red-hat-developer-hub/cli`, or through RHDH Dynamic Plugin Factory, rather than adding the CLI as a project dependency. ## Development diff --git a/src/commands/new/command.ts b/src/commands/new/command.ts index eead048..f787ecb 100644 --- a/src/commands/new/command.ts +++ b/src/commands/new/command.ts @@ -21,7 +21,7 @@ import { createInterface } from 'node:readline/promises'; import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; -import { templatingTask } from '../../lib/tasks'; +import { Task, templatingTask } from '../../lib/tasks'; export const pluginTypes = ['frontend', 'backend', 'backend-module'] as const; export type PluginType = (typeof pluginTypes)[number]; @@ -34,10 +34,16 @@ export interface CreatePluginOptions { manifestFile?: string; } +export interface PluginProjectResult { + outputDir: string; + rhdhVersion: string; + backstageVersion: string; +} + type Prompt = (question: string) => Promise; function assertPluginName(name: string): void { - if (!/^[a-z][a-z0-9-]*$/.test(name)) { + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) { throw new Error( 'Plugin name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.', ); @@ -56,15 +62,14 @@ export async function completeInteractiveOptions( options: CreatePluginOptions, prompt: Prompt, ): Promise { - return { - ...options, - name: options.name || (await prompt('Plugin name: ')).trim(), - type: - options.type || - ( - await prompt('Plugin type (frontend, backend, backend-module): ') - ).trim(), - }; + const name = options.name || (await prompt('Plugin name: ')).trim(); + const type = + options.type || + (await prompt('Plugin type (frontend, backend, backend-module): ')).trim(); + if (!name || !type) { + throw new Error('Plugin name and type cannot be empty.'); + } + return { ...options, name, type }; } async function promptForMissingOptions( @@ -74,8 +79,12 @@ async function promptForMissingOptions( return options; } if (!process.stdin.isTTY) { + const missing = [ + !options.name && 'plugin name', + !options.type && '--type', + ].filter(Boolean); throw new Error( - 'Plugin name and --type are required when running without an interactive terminal.', + `${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} required when running without an interactive terminal.`, ); } @@ -95,11 +104,7 @@ async function promptForMissingOptions( /** Creates a standalone plugin project from a version-pinned template. */ export async function createPluginProject( options: CreatePluginOptions, -): Promise<{ - outputDir: string; - rhdhVersion: string; - backstageVersion: string; -}> { +): Promise { if (!options.name) { throw new Error( 'Plugin name is required. Pass it as an argument or with --name.', @@ -109,7 +114,8 @@ export async function createPluginProject( assertPluginType(options.type); const outputDir = path.resolve(options.output || options.name); - if (await fs.pathExists(outputDir)) { + const outputExisted = await fs.pathExists(outputDir); + if (outputExisted) { const contents = await fs.readdir(outputDir); if (contents.length > 0) { throw new Error(`Output directory "${outputDir}" is not empty.`); @@ -131,18 +137,25 @@ export async function createPluginProject( const templateDir = paths.resolveOwn(`templates/plugin-new/${options.type}`); await fs.ensureDir(outputDir); - await templatingTask( - templateDir, - outputDir, - { - pluginId: options.name, - packageName: `@internal/backstage-plugin-${options.name}`, - rhdhVersion: resolved.rhdhVersion, - backstageVersion: resolved.backstageVersion, - }, - versionProvider, - false, - ); + try { + await templatingTask( + templateDir, + outputDir, + { + pluginId: options.name, + packageName: `@internal/backstage-plugin-${options.name}`, + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + }, + versionProvider, + false, + ); + } catch (error) { + if (!outputExisted) { + await fs.remove(outputDir); + } + throw error; + } return { outputDir, @@ -171,7 +184,7 @@ export async function command( }); const result = await createPluginProject(options); - process.stdout.write( + Task.log( `Created plugin in ${result.outputDir} for RHDH ${result.rhdhVersion} (Backstage ${result.backstageVersion}).\n`, ); } diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index fd5ec6f..6f63504 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -157,7 +157,11 @@ export async function fetchRemoteRhdhMetadata( const validBsVersion = semver.clean(bsVersion); if (validBsVersion) { return { - rhdhVersion: resolvedRhdhVersion, + rhdhVersion: + typeof resolvedRhdhVersion === 'string' && + /^[a-z0-9._-]+$/i.test(resolvedRhdhVersion) + ? resolvedRhdhVersion + : rhdhVersion, backstageVersion: validBsVersion, }; } diff --git a/templates/plugin-new/backend-module/src/index.ts.hbs b/templates/plugin-new/backend-module/src/index.ts.hbs index 9046d4b..e316cfb 100644 --- a/templates/plugin-new/backend-module/src/index.ts.hbs +++ b/templates/plugin-new/backend-module/src/index.ts.hbs @@ -8,7 +8,7 @@ const processor: CatalogProcessor = { getProcessorName: () => '{{pluginId}}Processor', }; -const module = createBackendModule({ +const catalogBackendModule = createBackendModule({ pluginId: 'catalog', moduleId: '{{pluginId}}', register(reg) { @@ -21,4 +21,4 @@ const module = createBackendModule({ }, }); -export default module; +export default catalogBackendModule; From a204e701e3cf12ad052b95058b5b4fc7812a7f4e Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Mon, 14 Sep 2026 08:22:27 -0400 Subject: [PATCH 3/7] chore: bump version to 2.0.7 and update changelog Assisted-By: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- CHANGELOG.md | 6 +++++- package.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64440fc..6114625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ 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). -## Unreleased +## 2.0.7 - 2026-09-14 + +### Added + +- **`plugin new`:** Add `rhdh-cli plugin new ` to create standalone, version-pinned frontend, backend, and catalog backend-module dynamic plugin projects ([RHIDP-16671](https://redhat.atlassian.net/browse/RHIDP-16671), [RHIDP-16668](https://redhat.atlassian.net/browse/RHIDP-16668), [#202](https://github.com/redhat-developer/rhdh-cli/pull/202)). Generated projects use the selected RHDH release's Backstage manifest and Yarn 4 configuration. ### Fixed diff --git a/package.json b/package.json index 16af1f8..69a27b8 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.6", + "version": "2.0.7", "publishConfig": { "access": "public" }, From e4107f6e581b48be25dc4e9a4399843059262399 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Mon, 14 Sep 2026 09:12:59 -0400 Subject: [PATCH 4/7] fix: address plugin new review findings Assisted-By: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- CHANGELOG.md | 1 + src/commands/new/command.test.ts | 21 +++++++++++++++++++++ src/commands/new/command.ts | 2 ++ src/commands/new/index.ts | 16 ++++++++++++++++ src/lib/rhdhVersion.ts | 3 ++- 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6114625..976e3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Update the RHDH 2.1.0, `main`, and `next` compatibility mappings to Backstage 1.54.6. +- Fall back to the requested RHDH version when remote metadata returns an invalid version value. ## 2.0.6 - 2026-09-11 diff --git a/src/commands/new/command.test.ts b/src/commands/new/command.test.ts index 87f6671..097a10e 100644 --- a/src/commands/new/command.test.ts +++ b/src/commands/new/command.test.ts @@ -124,6 +124,27 @@ describe('createPluginProject', () => { expect(mockResolveRhdhVersion).not.toHaveBeenCalled(); }); + it('cleans an existing empty output directory after template rendering fails', async () => { + const output = path.join(tmpDir, 'existing-empty'); + await fs.ensureDir(output); + mockResolveRhdhVersion.mockResolvedValueOnce({ + rhdhVersion: '2.1.0', + backstageVersion: '1.54.0', + source: 'matrix', + packages: new Map(), + }); + + await expect( + createPluginProject({ + name: 'example', + type: 'frontend', + output, + }), + ).rejects.toThrow('does not contain'); + + await expect(fs.readdir(output)).resolves.toEqual([]); + }); + it('prompts only for missing name and plugin type', async () => { const prompt = jest .fn, [string]>() diff --git a/src/commands/new/command.ts b/src/commands/new/command.ts index f787ecb..73c0fd5 100644 --- a/src/commands/new/command.ts +++ b/src/commands/new/command.ts @@ -153,6 +153,8 @@ export async function createPluginProject( } catch (error) { if (!outputExisted) { await fs.remove(outputDir); + } else { + await fs.emptyDir(outputDir); } throw error; } diff --git a/src/commands/new/index.ts b/src/commands/new/index.ts index 2588449..ebaffcf 100644 --- a/src/commands/new/index.ts +++ b/src/commands/new/index.ts @@ -1 +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/rhdhVersion.ts b/src/lib/rhdhVersion.ts index 6f63504..fad6b60 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -112,7 +112,8 @@ export function getRhdhGitRef(version: string): string | undefined { } /** - * Fetches build-metadata.json from the target RHDH repository branch. + * Fetches build-metadata.json from the target RHDH repository branch. Invalid + * RHDH version values from remote metadata fall back to the requested version. */ export async function fetchRemoteRhdhMetadata( rhdhVersion: string, From 4777888f07e3cef33b027bd2b24ae95b13a5b1c0 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Tue, 15 Sep 2026 05:57:58 -0400 Subject: [PATCH 5/7] fix: address plugin new review feedback Assisted-By: OpenCode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- CHANGELOG.md | 2 +- README.md | 2 +- src/commands/index.ts | 5 +- .../new/__snapshots__/command.test.ts.snap | 370 ++++++++++++++++++ src/commands/new/command.test.ts | 23 +- src/commands/new/command.ts | 12 +- .../backend-module/backstage.json.hbs | 1 - templates/plugin-new/backend/.gitignore | 5 + templates/plugin-new/backend/src/index.ts.hbs | 28 +- .../catalog-processor-module/.gitignore | 5 + .../.yarnrc.yml | 0 .../README.md.hbs | 2 +- .../backstage.json.hbs | 1 + .../package.json.hbs | 2 +- .../src/index.ts.hbs | 0 .../tsconfig.json | 0 templates/plugin-new/frontend/.gitignore | 5 + 17 files changed, 452 insertions(+), 11 deletions(-) create mode 100644 src/commands/new/__snapshots__/command.test.ts.snap delete mode 100644 templates/plugin-new/backend-module/backstage.json.hbs create mode 100644 templates/plugin-new/backend/.gitignore create mode 100644 templates/plugin-new/catalog-processor-module/.gitignore rename templates/plugin-new/{backend-module => catalog-processor-module}/.yarnrc.yml (100%) rename templates/plugin-new/{backend-module => catalog-processor-module}/README.md.hbs (85%) create mode 100644 templates/plugin-new/catalog-processor-module/backstage.json.hbs rename templates/plugin-new/{backend-module => catalog-processor-module}/package.json.hbs (94%) rename templates/plugin-new/{backend-module => catalog-processor-module}/src/index.ts.hbs (100%) rename templates/plugin-new/{backend-module => catalog-processor-module}/tsconfig.json (100%) create mode 100644 templates/plugin-new/frontend/.gitignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 976e3b3..60ed5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- **`plugin new`:** Add `rhdh-cli plugin new ` to create standalone, version-pinned frontend, backend, and catalog backend-module dynamic plugin projects ([RHIDP-16671](https://redhat.atlassian.net/browse/RHIDP-16671), [RHIDP-16668](https://redhat.atlassian.net/browse/RHIDP-16668), [#202](https://github.com/redhat-developer/rhdh-cli/pull/202)). Generated projects use the selected RHDH release's Backstage manifest and Yarn 4 configuration. +- **`plugin new`:** Add `rhdh-cli plugin new ` to create standalone, version-pinned frontend, backend, and catalog processor module dynamic plugin projects ([RHIDP-16671](https://redhat.atlassian.net/browse/RHIDP-16671), [RHIDP-16668](https://redhat.atlassian.net/browse/RHIDP-16668), [#202](https://github.com/redhat-developer/rhdh-cli/pull/202)). Generated projects use the selected RHDH release's Backstage manifest and Yarn 4 configuration. ### Fixed diff --git a/README.md b/README.md index e9bfa27..e08c6df 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Use `plugin new` to create a standalone, version-pinned dynamic plugin project: rhdh-cli plugin new my-plugin --type frontend --rhdh-version 2.1.0 ``` -Supported types are `frontend` (a New Frontend System, or NFS, page), `backend` (a minimal new-backend-system plugin), and `backend-module` (a catalog processor module). Use `--name ` as an alternative to the positional name, and `--output ` to select a destination. The generated project uses the target RHDH release's Backstage manifest for every `@backstage/*` dependency. For air-gapped environments, provide `--manifest-file` and set `RHDH_OFFLINE=true`. Export and package generated plugins with `npx @red-hat-developer-hub/cli`, or through RHDH Dynamic Plugin Factory, rather than adding the CLI as a project dependency. +Supported types are `frontend` (a New Frontend System, or NFS, page), `backend` (a minimal new-backend-system plugin), and `catalog-processor-module` (a catalog processor module). Use `--name ` as an alternative to the positional name, and `--output ` to select a destination. The generated project uses the target RHDH release's Backstage manifest for every `@backstage/*` dependency. For air-gapped environments, provide `--manifest-file` and set `RHDH_OFFLINE=true`. Export and package generated plugins with `npx @red-hat-developer-hub/cli`, or through RHDH Dynamic Plugin Factory, rather than adding the CLI as a project dependency. ## Development diff --git a/src/commands/index.ts b/src/commands/index.ts index b717638..1a4df5c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -191,7 +191,10 @@ export function registerPluginCommand(program: Command) { .command('new [name]') .description('Create a standalone RHDH dynamic plugin project') .option('--name ', 'Plugin name (alternative to the argument)') - .option('--type ', 'Plugin type to create') + .option( + '--type ', + 'Plugin type to create', + ) .option('--rhdh-version ', 'Target RHDH version') .option( '--output ', diff --git a/src/commands/new/__snapshots__/command.test.ts.snap b/src/commands/new/__snapshots__/command.test.ts.snap new file mode 100644 index 0000000..a6e9802 --- /dev/null +++ b/src/commands/new/__snapshots__/command.test.ts.snap @@ -0,0 +1,370 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createPluginProject creates a backend plugin project 1`] = ` +[ + [ + ".gitignore", + "node_modules +dist +dist-types +coverage +*.tsbuildinfo +", + ], + [ + ".yarnrc.yml", + "nodeLinker: node-modules +", + ], + [ + "README.md", + "# example-plugin + +An RHDH dynamic backend plugin using the New Backend System. + +## Development + +\`\`\`bash +yarn install +yarn build +yarn test +\`\`\` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +\`\`\`bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +\`\`\` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. +", + ], + [ + "backstage.json", + "{ "version": "1.54.0" }", + ], + [ + "package.json", + "{ + "name": "@internal/backstage-plugin-example-plugin-backend", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { "role": "backend-plugin", "pluginId": "example-plugin" }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { "@backstage/backend-plugin-api": "1.10.0" }, + "devDependencies": { + "@backstage/cli": "0.36.5", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.4.5" + } +} +", + ], + [ + "src/index.ts", + "import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +const plugin = createBackendPlugin({ + pluginId: 'example-plugin', + register(reg) { + reg.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + }, + async init({ httpRouter, logger }) { + httpRouter.addAuthPolicy({ + path: '/health', + allow: 'unauthenticated', + }); + httpRouter.use((request, response, next) => { + if (request.path !== '/health') { + next(); + return; + } + logger.info('Health check requested'); + response.status(200).json({ status: 'ok' }); + }); + }, + }); + }, +}); + +export default plugin; +", + ], + [ + "tsconfig.json", + "{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} +", + ], +] +`; + +exports[`createPluginProject creates a catalog-processor-module plugin project 1`] = ` +[ + [ + ".gitignore", + "node_modules +dist +dist-types +coverage +*.tsbuildinfo +", + ], + [ + ".yarnrc.yml", + "nodeLinker: node-modules +", + ], + [ + "README.md", + "# example-plugin + +An RHDH dynamic catalog processor module. + +## Development + +\`\`\`bash +yarn install +yarn build +yarn test +\`\`\` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +\`\`\`bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +\`\`\` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. +", + ], + [ + "backstage.json", + "{ "version": "1.54.0" } +", + ], + [ + "package.json", + "{ + "name": "@internal/backstage-plugin-example-plugin-catalog-processor-module", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { + "@backstage/backend-plugin-api": "1.10.0", + "@backstage/catalog-model": "1.10.0", + "@backstage/plugin-catalog-node": "2.2.4" + }, + "devDependencies": { + "@backstage/cli": "0.36.5", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.4.5" + } +} +", + ], + [ + "src/index.ts", + "import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + catalogProcessingExtensionPoint, + type CatalogProcessor, +} from '@backstage/plugin-catalog-node'; + +const processor: CatalogProcessor = { + getProcessorName: () => 'example-pluginProcessor', +}; + +const catalogBackendModule = createBackendModule({ + pluginId: 'catalog', + moduleId: 'example-plugin', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(processor); + }, + }); + }, +}); + +export default catalogBackendModule; +", + ], + [ + "tsconfig.json", + "{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} +", + ], +] +`; + +exports[`createPluginProject creates a frontend plugin project 1`] = ` +[ + [ + ".gitignore", + "node_modules +dist +dist-types +coverage +*.tsbuildinfo +", + ], + [ + ".yarnrc.yml", + "nodeLinker: node-modules +", + ], + [ + "README.md", + "# example-plugin + +An RHDH dynamic frontend plugin using the New Frontend System. + +## Development + +\`\`\`bash +yarn install +yarn build +yarn test +\`\`\` + +## Exporting + +Export or package this plugin without adding the RHDH CLI to this project: + +\`\`\`bash +npx @red-hat-developer-hub/cli plugin export +npx @red-hat-developer-hub/cli plugin package --export-to ./plugin-registry +\`\`\` + +RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and packaging automation. +", + ], + [ + "backstage.json", + "{ "version": "1.54.0" }", + ], + [ + "package.json", + "{ + "name": "@internal/backstage-plugin-example-plugin", + "version": "0.1.0", + "packageManager": "yarn@4.17.1", + "private": true, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "backstage": { "role": "frontend-plugin", "pluginId": "example-plugin" }, + "scripts": { + "build": "yarn tsc && backstage-cli package build", + "tsc": "tsc --declaration", + "test": "backstage-cli package test --passWithNoTests" + }, + "dependencies": { + "@backstage/core-plugin-api": "1.12.7", + "@backstage/frontend-plugin-api": "0.17.2" + }, + "devDependencies": { + "@backstage/cli": "0.36.5", + "@types/jest": "^29.5.14", + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.30.2", + "jest": "^29.7.0", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.30.2" + } +} +", + ], + [ + "src/index.ts", + "import { createRouteRef } from '@backstage/core-plugin-api'; +import { createFrontendPlugin, PageBlueprint } from '@backstage/frontend-plugin-api'; +import React from 'react'; + +const rootRouteRef = createRouteRef({ id: 'example-plugin' }); +const page = PageBlueprint.make({ + params: { + title: 'example-plugin', + path: '/example-plugin', + routeRef: rootRouteRef, + loader: () => import('./PluginPage').then(m => React.createElement(m.PluginPage)), + }, +}); + +export default createFrontendPlugin({ + pluginId: 'example-plugin', + extensions: [page], + routes: { root: rootRouteRef }, +}); +", + ], + [ + "tsconfig.json", + "{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} +", + ], +] +`; diff --git a/src/commands/new/command.test.ts b/src/commands/new/command.test.ts index 097a10e..a204429 100644 --- a/src/commands/new/command.test.ts +++ b/src/commands/new/command.test.ts @@ -57,7 +57,11 @@ describe('createPluginProject', () => { it.each([ ['frontend', 'src/index.ts', 'PageBlueprint'], ['backend', 'src/index.ts', 'createBackendPlugin'], - ['backend-module', 'src/index.ts', 'catalogProcessingExtensionPoint'], + [ + 'catalog-processor-module', + 'src/index.ts', + 'catalogProcessingExtensionPoint', + ], ])( 'creates a %s plugin project', async (type, entryPoint, expectedSource) => { @@ -97,6 +101,23 @@ describe('createPluginProject', () => { await expect( fs.readJson(path.join(output, 'backstage.json')), ).resolves.toEqual({ version: '1.54.0' }); + + await expect( + Promise.all( + [ + '.gitignore', + '.yarnrc.yml', + 'README.md', + 'backstage.json', + 'package.json', + 'src/index.ts', + 'tsconfig.json', + ].map(async file => [ + file, + await fs.readFile(path.join(output, file), 'utf8'), + ]), + ), + ).resolves.toMatchSnapshot(); }, ); diff --git a/src/commands/new/command.ts b/src/commands/new/command.ts index 73c0fd5..b73a1e4 100644 --- a/src/commands/new/command.ts +++ b/src/commands/new/command.ts @@ -23,7 +23,11 @@ import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; import { Task, templatingTask } from '../../lib/tasks'; -export const pluginTypes = ['frontend', 'backend', 'backend-module'] as const; +export const pluginTypes = [ + 'frontend', + 'backend', + 'catalog-processor-module', +] as const; export type PluginType = (typeof pluginTypes)[number]; export interface CreatePluginOptions { @@ -65,7 +69,11 @@ export async function completeInteractiveOptions( const name = options.name || (await prompt('Plugin name: ')).trim(); const type = options.type || - (await prompt('Plugin type (frontend, backend, backend-module): ')).trim(); + ( + await prompt( + 'Plugin type (frontend, backend, catalog-processor-module): ', + ) + ).trim(); if (!name || !type) { throw new Error('Plugin name and type cannot be empty.'); } diff --git a/templates/plugin-new/backend-module/backstage.json.hbs b/templates/plugin-new/backend-module/backstage.json.hbs deleted file mode 100644 index 0ba5066..0000000 --- a/templates/plugin-new/backend-module/backstage.json.hbs +++ /dev/null @@ -1 +0,0 @@ -{ "version": "{{backstageVersion}}" } \ No newline at end of file diff --git a/templates/plugin-new/backend/.gitignore b/templates/plugin-new/backend/.gitignore new file mode 100644 index 0000000..0140d72 --- /dev/null +++ b/templates/plugin-new/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +dist-types +coverage +*.tsbuildinfo diff --git a/templates/plugin-new/backend/src/index.ts.hbs b/templates/plugin-new/backend/src/index.ts.hbs index 1a0b3aa..9354289 100644 --- a/templates/plugin-new/backend/src/index.ts.hbs +++ b/templates/plugin-new/backend/src/index.ts.hbs @@ -1,8 +1,32 @@ -import { createBackendPlugin } from '@backstage/backend-plugin-api'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; const plugin = createBackendPlugin({ pluginId: '{{pluginId}}', - register() {}, + register(reg) { + reg.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + }, + async init({ httpRouter, logger }) { + httpRouter.addAuthPolicy({ + path: '/health', + allow: 'unauthenticated', + }); + httpRouter.use((request, response, next) => { + if (request.path !== '/health') { + next(); + return; + } + logger.info('Health check requested'); + response.status(200).json({ status: 'ok' }); + }); + }, + }); + }, }); export default plugin; diff --git a/templates/plugin-new/catalog-processor-module/.gitignore b/templates/plugin-new/catalog-processor-module/.gitignore new file mode 100644 index 0000000..0140d72 --- /dev/null +++ b/templates/plugin-new/catalog-processor-module/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +dist-types +coverage +*.tsbuildinfo diff --git a/templates/plugin-new/backend-module/.yarnrc.yml b/templates/plugin-new/catalog-processor-module/.yarnrc.yml similarity index 100% rename from templates/plugin-new/backend-module/.yarnrc.yml rename to templates/plugin-new/catalog-processor-module/.yarnrc.yml diff --git a/templates/plugin-new/backend-module/README.md.hbs b/templates/plugin-new/catalog-processor-module/README.md.hbs similarity index 85% rename from templates/plugin-new/backend-module/README.md.hbs rename to templates/plugin-new/catalog-processor-module/README.md.hbs index 935027a..c1eeded 100644 --- a/templates/plugin-new/backend-module/README.md.hbs +++ b/templates/plugin-new/catalog-processor-module/README.md.hbs @@ -1,6 +1,6 @@ # {{pluginId}} -An RHDH dynamic backend module that registers a catalog processor. +An RHDH dynamic catalog processor module. ## Development diff --git a/templates/plugin-new/catalog-processor-module/backstage.json.hbs b/templates/plugin-new/catalog-processor-module/backstage.json.hbs new file mode 100644 index 0000000..97d49c2 --- /dev/null +++ b/templates/plugin-new/catalog-processor-module/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } diff --git a/templates/plugin-new/backend-module/package.json.hbs b/templates/plugin-new/catalog-processor-module/package.json.hbs similarity index 94% rename from templates/plugin-new/backend-module/package.json.hbs rename to templates/plugin-new/catalog-processor-module/package.json.hbs index 3673214..e974241 100644 --- a/templates/plugin-new/backend-module/package.json.hbs +++ b/templates/plugin-new/catalog-processor-module/package.json.hbs @@ -1,5 +1,5 @@ { - "name": "{{packageName}}-catalog-backend-module", + "name": "{{packageName}}-catalog-processor-module", "version": "0.1.0", "packageManager": "yarn@4.17.1", "private": true, diff --git a/templates/plugin-new/backend-module/src/index.ts.hbs b/templates/plugin-new/catalog-processor-module/src/index.ts.hbs similarity index 100% rename from templates/plugin-new/backend-module/src/index.ts.hbs rename to templates/plugin-new/catalog-processor-module/src/index.ts.hbs diff --git a/templates/plugin-new/backend-module/tsconfig.json b/templates/plugin-new/catalog-processor-module/tsconfig.json similarity index 100% rename from templates/plugin-new/backend-module/tsconfig.json rename to templates/plugin-new/catalog-processor-module/tsconfig.json diff --git a/templates/plugin-new/frontend/.gitignore b/templates/plugin-new/frontend/.gitignore new file mode 100644 index 0000000..0140d72 --- /dev/null +++ b/templates/plugin-new/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +dist-types +coverage +*.tsbuildinfo From 22f634248240563bf2b04b84116e28eaf795ce57 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Tue, 15 Sep 2026 06:05:35 -0400 Subject: [PATCH 6/7] test: cover all generated plugin files Assisted-By: OpenCode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/new/__snapshots__/command.test.ts.snap | 9 +++++++++ src/commands/new/command.test.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/src/commands/new/__snapshots__/command.test.ts.snap b/src/commands/new/__snapshots__/command.test.ts.snap index a6e9802..1e79c3c 100644 --- a/src/commands/new/__snapshots__/command.test.ts.snap +++ b/src/commands/new/__snapshots__/command.test.ts.snap @@ -364,6 +364,15 @@ export default createFrontendPlugin({ "rootDir": "." } } +", + ], + [ + "src/PluginPage.tsx", + "import React from 'react'; + +export function PluginPage() { + return React.createElement('h1', undefined, 'example-plugin'); +} ", ], ] diff --git a/src/commands/new/command.test.ts b/src/commands/new/command.test.ts index a204429..47f5bad 100644 --- a/src/commands/new/command.test.ts +++ b/src/commands/new/command.test.ts @@ -112,6 +112,7 @@ describe('createPluginProject', () => { 'package.json', 'src/index.ts', 'tsconfig.json', + ...(type === 'frontend' ? ['src/PluginPage.tsx'] : []), ].map(async file => [ file, await fs.readFile(path.join(output, file), 'utf8'), From 57267ae844d03d51505246f5cd3f776fa669c79d Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Tue, 15 Sep 2026 06:50:43 -0400 Subject: [PATCH 7/7] fix: address remaining plugin new review feedback Assisted-By: OpenCode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/index.ts | 1 + .../new/__snapshots__/command.test.ts.snap | 6 ++++-- src/commands/new/command.test.ts | 20 +++++++++++++++++++ src/commands/new/command.ts | 9 ++++----- src/lib/rhdhVersion.test.ts | 16 +++++++++++++++ .../plugin-new/backend/backstage.json.hbs | 2 +- .../plugin-new/frontend/backstage.json.hbs | 2 +- 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index 1a4df5c..f960646 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -187,6 +187,7 @@ export function registerPluginCommand(program: Command) { ) .option('--json', 'Output upgrade results as JSON') .action(lazy(() => import('./upgrade').then(m => m.command))); + command .command('new [name]') .description('Create a standalone RHDH dynamic plugin project') diff --git a/src/commands/new/__snapshots__/command.test.ts.snap b/src/commands/new/__snapshots__/command.test.ts.snap index 1e79c3c..8b87bdb 100644 --- a/src/commands/new/__snapshots__/command.test.ts.snap +++ b/src/commands/new/__snapshots__/command.test.ts.snap @@ -44,7 +44,8 @@ RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and p ], [ "backstage.json", - "{ "version": "1.54.0" }", + "{ "version": "1.54.0" } +", ], [ "package.json", @@ -291,7 +292,8 @@ RHDH Dynamic Plugin Factory is the preferred workflow for repeatable build and p ], [ "backstage.json", - "{ "version": "1.54.0" }", + "{ "version": "1.54.0" } +", ], [ "package.json", diff --git a/src/commands/new/command.test.ts b/src/commands/new/command.test.ts index 47f5bad..619db2f 100644 --- a/src/commands/new/command.test.ts +++ b/src/commands/new/command.test.ts @@ -167,6 +167,26 @@ describe('createPluginProject', () => { await expect(fs.readdir(output)).resolves.toEqual([]); }); + it('removes a newly created output directory after template rendering fails', async () => { + const output = path.join(tmpDir, 'new-output'); + mockResolveRhdhVersion.mockResolvedValueOnce({ + rhdhVersion: '2.1.0', + backstageVersion: '1.54.0', + source: 'matrix', + packages: new Map(), + }); + + await expect( + createPluginProject({ + name: 'example', + type: 'frontend', + output, + }), + ).rejects.toThrow('does not contain'); + + await expect(fs.pathExists(output)).resolves.toBe(false); + }); + it('prompts only for missing name and plugin type', async () => { const prompt = jest .fn, [string]>() diff --git a/src/commands/new/command.ts b/src/commands/new/command.ts index b73a1e4..1d85b43 100644 --- a/src/commands/new/command.ts +++ b/src/commands/new/command.ts @@ -62,6 +62,7 @@ function assertPluginType( } } +/** Prompts for any plugin creation options omitted by the caller. */ export async function completeInteractiveOptions( options: CreatePluginOptions, prompt: Prompt, @@ -159,11 +160,9 @@ export async function createPluginProject( false, ); } catch (error) { - if (!outputExisted) { - await fs.remove(outputDir); - } else { - await fs.emptyDir(outputDir); - } + await (outputExisted ? fs.emptyDir(outputDir) : fs.remove(outputDir)).catch( + () => undefined, + ); throw error; } diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index e6570cc..328da75 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -224,6 +224,22 @@ describe('rhdhVersion', () => { await expect(fetchRemoteRhdhMetadata('2.0.0')).resolves.toBeUndefined(); }); + + it('falls back to the requested RHDH version when metadata is invalid', async () => { + setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0::warning', + 'Backstage Version': '1.52.0', + }, + }, + }); + + await expect(fetchRemoteRhdhMetadata('2.0.0')).resolves.toEqual({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + }); + }); }); describe('resolveRhdhVersion', () => { diff --git a/templates/plugin-new/backend/backstage.json.hbs b/templates/plugin-new/backend/backstage.json.hbs index 0ba5066..97d49c2 100644 --- a/templates/plugin-new/backend/backstage.json.hbs +++ b/templates/plugin-new/backend/backstage.json.hbs @@ -1 +1 @@ -{ "version": "{{backstageVersion}}" } \ No newline at end of file +{ "version": "{{backstageVersion}}" } diff --git a/templates/plugin-new/frontend/backstage.json.hbs b/templates/plugin-new/frontend/backstage.json.hbs index 0ba5066..97d49c2 100644 --- a/templates/plugin-new/frontend/backstage.json.hbs +++ b/templates/plugin-new/frontend/backstage.json.hbs @@ -1 +1 @@ -{ "version": "{{backstageVersion}}" } \ No newline at end of file +{ "version": "{{backstageVersion}}" }