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/CHANGELOG.md b/CHANGELOG.md index 494f632..60ed5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.7 - 2026-09-14 + +### Added + +- **`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 + +- 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 ### Added diff --git a/README.md b/README.md index 1527ac6..e08c6df 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` (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 ### Contributing 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" }, diff --git a/src/commands/index.ts b/src/commands/index.ts index 3fbb95c..f960646 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -187,6 +187,25 @@ 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/__snapshots__/command.test.ts.snap b/src/commands/new/__snapshots__/command.test.ts.snap new file mode 100644 index 0000000..8b87bdb --- /dev/null +++ b/src/commands/new/__snapshots__/command.test.ts.snap @@ -0,0 +1,381 @@ +// 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": "." + } +} +", + ], + [ + "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 new file mode 100644 index 0000000..619db2f --- /dev/null +++ b/src/commands/new/command.test.ts @@ -0,0 +1,205 @@ +/* + * 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'], + [ + 'catalog-processor-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' }); + + await expect( + Promise.all( + [ + '.gitignore', + '.yarnrc.yml', + 'README.md', + 'backstage.json', + 'package.json', + 'src/index.ts', + 'tsconfig.json', + ...(type === 'frontend' ? ['src/PluginPage.tsx'] : []), + ].map(async file => [ + file, + await fs.readFile(path.join(output, file), 'utf8'), + ]), + ), + ).resolves.toMatchSnapshot(); + }, + ); + + 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('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('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]>() + .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..1d85b43 --- /dev/null +++ b/src/commands/new/command.ts @@ -0,0 +1,199 @@ +/* + * 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 { Task, templatingTask } from '../../lib/tasks'; + +export const pluginTypes = [ + 'frontend', + 'backend', + 'catalog-processor-module', +] as const; +export type PluginType = (typeof pluginTypes)[number]; + +export interface CreatePluginOptions { + name?: string; + type?: string; + output?: string; + rhdhVersion?: string; + 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]*(-[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(', ')}.`); + } +} + +/** Prompts for any plugin creation options omitted by the caller. */ +export async function completeInteractiveOptions( + options: CreatePluginOptions, + prompt: Prompt, +): Promise { + const name = options.name || (await prompt('Plugin name: ')).trim(); + const type = + options.type || + ( + await prompt( + 'Plugin type (frontend, backend, catalog-processor-module): ', + ) + ).trim(); + if (!name || !type) { + throw new Error('Plugin name and type cannot be empty.'); + } + return { ...options, name, type }; +} + +async function promptForMissingOptions( + options: CreatePluginOptions, +): Promise { + if (options.name && options.type) { + return options; + } + if (!process.stdin.isTTY) { + const missing = [ + !options.name && 'plugin name', + !options.type && '--type', + ].filter(Boolean); + throw new Error( + `${missing.join(' and ')} ${missing.length === 1 ? 'is' : '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 { + 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); + 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.`); + } + } + + 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); + try { + await templatingTask( + templateDir, + outputDir, + { + pluginId: options.name, + packageName: `@internal/backstage-plugin-${options.name}`, + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + }, + versionProvider, + false, + ); + } catch (error) { + await (outputExisted ? fs.emptyDir(outputDir) : fs.remove(outputDir)).catch( + () => undefined, + ); + throw error; + } + + 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); + + Task.log( + `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..ebaffcf --- /dev/null +++ b/src/commands/new/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { command } from './command'; diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index de56319..328da75 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', () => { @@ -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/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index 35aaa10..fad6b60 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', }; /** @@ -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, @@ -157,7 +158,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/.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/.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..97d49c2 --- /dev/null +++ b/templates/plugin-new/backend/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } 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..9354289 --- /dev/null +++ b/templates/plugin-new/backend/src/index.ts.hbs @@ -0,0 +1,32 @@ +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +const plugin = createBackendPlugin({ + pluginId: '{{pluginId}}', + 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/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/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/catalog-processor-module/.yarnrc.yml b/templates/plugin-new/catalog-processor-module/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/templates/plugin-new/catalog-processor-module/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/templates/plugin-new/catalog-processor-module/README.md.hbs b/templates/plugin-new/catalog-processor-module/README.md.hbs new file mode 100644 index 0000000..c1eeded --- /dev/null +++ b/templates/plugin-new/catalog-processor-module/README.md.hbs @@ -0,0 +1,22 @@ +# {{pluginId}} + +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. 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/catalog-processor-module/package.json.hbs b/templates/plugin-new/catalog-processor-module/package.json.hbs new file mode 100644 index 0000000..e974241 --- /dev/null +++ b/templates/plugin-new/catalog-processor-module/package.json.hbs @@ -0,0 +1,30 @@ +{ + "name": "{{packageName}}-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": "{{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/catalog-processor-module/src/index.ts.hbs b/templates/plugin-new/catalog-processor-module/src/index.ts.hbs new file mode 100644 index 0000000..e316cfb --- /dev/null +++ b/templates/plugin-new/catalog-processor-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 catalogBackendModule = createBackendModule({ + pluginId: 'catalog', + moduleId: '{{pluginId}}', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(processor); + }, + }); + }, +}); + +export default catalogBackendModule; diff --git a/templates/plugin-new/catalog-processor-module/tsconfig.json b/templates/plugin-new/catalog-processor-module/tsconfig.json new file mode 100644 index 0000000..7e1995b --- /dev/null +++ b/templates/plugin-new/catalog-processor-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/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 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..97d49c2 --- /dev/null +++ b/templates/plugin-new/frontend/backstage.json.hbs @@ -0,0 +1 @@ +{ "version": "{{backstageVersion}}" } 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": "." + } +}