diff --git a/.changeset/clever-insects-tan.md b/.changeset/clever-insects-tan.md new file mode 100644 index 0000000000..c04490a833 --- /dev/null +++ b/.changeset/clever-insects-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-maintenance': patch +--- + +Added auto-fill of `backstage.pluginPackage` metadata for known plugins during `repo fix`. diff --git a/.changeset/ninety-suits-drum.md b/.changeset/ninety-suits-drum.md new file mode 100644 index 0000000000..d2a0caf7ba --- /dev/null +++ b/.changeset/ninety-suits-drum.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli-module-new': patch +'@backstage/cli': patch +--- + +The `new` command now prompts for the plugin package name when creating plugin modules, in order to properly populate the `package.json` file. diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts index 281b998d92..dfd8968c6a 100644 --- a/packages/cli-internal/src/index.ts +++ b/packages/cli-internal/src/index.ts @@ -31,3 +31,7 @@ export { resetSecretStore, type SecretStore, } from './secretStore'; +export { + knownBackendPluginPackageNameByPluginId, + knownFrontendPluginPackageNameByPluginId, +} from './knownPluginPackages'; diff --git a/packages/cli-internal/src/knownPluginPackages.ts b/packages/cli-internal/src/knownPluginPackages.ts new file mode 100644 index 0000000000..c5d13beef1 --- /dev/null +++ b/packages/cli-internal/src/knownPluginPackages.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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. + */ + +const knownBackendPluginIds = [ + 'app', + 'auth', + 'catalog', + 'events', + 'kubernetes', + 'notifications', + 'permission', + 'proxy', + 'scaffolder', + 'search', + 'signals', + 'techdocs', +]; + +// Only includes plugin IDs that have a corresponding frontend package; some plugins are backend-only and not listed here. +const knownFrontendPluginIds = [ + 'app', + 'auth', + 'catalog', + 'kubernetes', + 'notifications', + 'scaffolder', + 'search', + 'signals', + 'techdocs', +]; + +/** + * Maps known plugin IDs to their corresponding backend package names. + */ +export const knownBackendPluginPackageNameByPluginId: Record = + Object.fromEntries( + knownBackendPluginIds.map(pluginId => [ + pluginId, + `@backstage/plugin-${pluginId}-backend`, + ]), + ); + +/** + * Maps known plugin IDs to their corresponding frontend package names. + */ +export const knownFrontendPluginPackageNameByPluginId: Record = + Object.fromEntries( + knownFrontendPluginIds.map(pluginId => [ + pluginId, + `@backstage/plugin-${pluginId}`, + ]), + ); diff --git a/packages/cli-module-maintenance/package.json b/packages/cli-module-maintenance/package.json index 24f7a9b70a..585de9d6cd 100644 --- a/packages/cli-module-maintenance/package.json +++ b/packages/cli-module-maintenance/package.json @@ -19,6 +19,7 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "bin": "bin/backstage-cli-module-maintenance", "files": [ "dist", "bin" @@ -42,6 +43,5 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@types/fs-extra": "^11.0.0" - }, - "bin": "bin/backstage-cli-module-maintenance" + } } diff --git a/packages/cli-module-maintenance/src/commands/repo/fix.ts b/packages/cli-module-maintenance/src/commands/repo/fix.ts index 4d6445fe86..c9e6a965bf 100644 --- a/packages/cli-module-maintenance/src/commands/repo/fix.ts +++ b/packages/cli-module-maintenance/src/commands/repo/fix.ts @@ -30,6 +30,10 @@ import { extname, } from 'node:path'; import { targetPaths } from '@backstage/cli-common'; +import { + knownBackendPluginPackageNameByPluginId, + knownFrontendPluginPackageNameByPluginId, +} from '@internal/cli'; const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json']; @@ -347,22 +351,6 @@ export function fixPluginId(pkg: FixablePackage) { } } -const backendPluginPackageNameByPluginId = new Map( - [ - 'app', - 'auth', - 'catalog', - 'events', - 'kubernetes', - 'notifications', - 'permission', - 'scaffolder', - 'search', - 'signals', - 'techdocs', - ].map(pluginId => [pluginId, `@backstage/plugin-${pluginId}-backend`]), -); - const pluginPackageRoles: Array = [ 'frontend-plugin', 'backend-plugin', @@ -415,8 +403,10 @@ export function fixPluginPackages( p => p.packageJson.backstage?.pluginId === pluginId && p.packageJson.backstage?.role === targetRole, - )?.packageJson.name ?? backendPluginPackageNameByPluginId.get(pluginId); - + )?.packageJson.name ?? + (role === 'backend-plugin-module' + ? knownBackendPluginPackageNameByPluginId[pluginId] + : knownFrontendPluginPackageNameByPluginId[pluginId]); if (!pluginPkgName) { // If we can't find a matching package in the repo but one is declared, skip if (pkgBackstage.pluginPackage) { diff --git a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts index 905ec0b0c5..cf1122d785 100644 --- a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts +++ b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts @@ -149,4 +149,204 @@ describe('collectTemplateParams', () => { ], }); }); + + describe('backend-plugin-module with pluginPackage', () => { + const backendModuleOptions = { + ...baseOptions, + template: { + name: 'test-module', + role: 'backend-plugin-module' as const, + files: [], + values: {}, + }, + }; + + it('should auto-fill pluginPackage for catalog plugin without prompting', async () => { + const promptSpy = jest.spyOn(inquirer, 'prompt'); + + await expect( + collectPortableTemplateInput({ + ...backendModuleOptions, + prefilledParams: { + pluginId: 'catalog', + moduleId: 'my-module', + }, + }), + ).resolves.toEqual({ + roleParams: { + role: 'backend-plugin-module', + pluginId: 'catalog', + moduleId: 'my-module', + pluginPackage: '@backstage/plugin-catalog-backend', + }, + owner: undefined, + version: '0.1.0', + license: 'Apache-2.0', + private: true, + packageName: '@internal/plugin-catalog-backend-module-my-module', + packagePath: 'plugins/catalog-backend-module-my-module', + }); + + const questions = promptSpy.mock.calls[0][0] as Array<{ name?: string }>; + expect(questions.some(q => q.name === 'pluginPackage')).toBe(false); + }); + + it('should prompt for pluginPackage for unknown plugins', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + pluginPackage: '@mycompany/plugin-custom-backend', + }); + + await expect( + collectPortableTemplateInput({ + ...backendModuleOptions, + prefilledParams: { + pluginId: 'custom', + moduleId: 'my-extension', + }, + }), + ).resolves.toEqual({ + roleParams: { + role: 'backend-plugin-module', + pluginId: 'custom', + moduleId: 'my-extension', + pluginPackage: '@mycompany/plugin-custom-backend', + }, + owner: undefined, + version: '0.1.0', + license: 'Apache-2.0', + private: true, + packageName: '@internal/plugin-custom-backend-module-my-extension', + packagePath: 'plugins/custom-backend-module-my-extension', + }); + }); + + it('should re-prompt when pluginPackage is prefilled with an empty string', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + pluginPackage: '@mycompany/plugin-custom-backend', + }); + + await expect( + collectPortableTemplateInput({ + ...backendModuleOptions, + prefilledParams: { + pluginId: 'custom', + moduleId: 'my-extension', + pluginPackage: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + roleParams: expect.objectContaining({ + pluginPackage: '@mycompany/plugin-custom-backend', + }), + }), + ); + + expect(inquirer.prompt).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'pluginPackage' }), + ]), + ); + }); + + it('should re-prompt when pluginPackage is prefilled with an invalid name', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + pluginPackage: '@mycompany/plugin-custom-backend', + }); + + await expect( + collectPortableTemplateInput({ + ...backendModuleOptions, + prefilledParams: { + pluginId: 'custom', + moduleId: 'my-extension', + pluginPackage: 'INVALID PACKAGE NAME!', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + roleParams: expect.objectContaining({ + pluginPackage: '@mycompany/plugin-custom-backend', + }), + }), + ); + + expect(inquirer.prompt).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'pluginPackage' }), + ]), + ); + }); + }); + + describe('frontend-plugin-module with pluginPackage', () => { + const frontendModuleOptions = { + ...baseOptions, + template: { + name: 'test-module', + role: 'frontend-plugin-module' as const, + files: [], + values: {}, + }, + }; + + it('should auto-fill pluginPackage for catalog plugin without prompting', async () => { + const promptSpy = jest.spyOn(inquirer, 'prompt'); + + await expect( + collectPortableTemplateInput({ + ...frontendModuleOptions, + prefilledParams: { + pluginId: 'catalog', + moduleId: 'my-module', + }, + }), + ).resolves.toEqual({ + roleParams: { + role: 'frontend-plugin-module', + pluginId: 'catalog', + moduleId: 'my-module', + pluginPackage: '@backstage/plugin-catalog', + }, + owner: undefined, + version: '0.1.0', + license: 'Apache-2.0', + private: true, + packageName: '@internal/plugin-catalog-module-my-module', + packagePath: 'plugins/catalog-module-my-module', + }); + + const questions = promptSpy.mock.calls[0][0] as Array<{ name?: string }>; + expect(questions.some(q => q.name === 'pluginPackage')).toBe(false); + }); + + it('should prompt for pluginPackage for unknown plugins', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + pluginPackage: '@mycompany/plugin-custom', + }); + + await expect( + collectPortableTemplateInput({ + ...frontendModuleOptions, + prefilledParams: { + pluginId: 'custom', + moduleId: 'my-extension', + }, + }), + ).resolves.toEqual({ + roleParams: { + role: 'frontend-plugin-module', + pluginId: 'custom', + moduleId: 'my-extension', + pluginPackage: '@mycompany/plugin-custom', + }, + owner: undefined, + version: '0.1.0', + license: 'Apache-2.0', + private: true, + packageName: '@internal/plugin-custom-module-my-extension', + packagePath: 'plugins/custom-module-my-extension', + }); + }); + }); }); diff --git a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts index 63e3791a49..341caae504 100644 --- a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts +++ b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts @@ -17,6 +17,10 @@ import inquirer, { DistinctQuestion } from 'inquirer'; import { getCodeownersFilePath, parseOwnerIds } from '../codeowners'; import { targetPaths } from '@backstage/cli-common'; +import { + knownBackendPluginPackageNameByPluginId, + knownFrontendPluginPackageNameByPluginId, +} from '@internal/cli'; import { PortableTemplateConfig, @@ -55,12 +59,37 @@ export async function collectPortableTemplateInput( deprecatedParams.pluginId = prefilledParams.id; } - const parameters = { + const parameters: PortableTemplateParams = { ...template.values, ...prefilledParams, ...deprecatedParams, }; + // Pre-populate pluginPackage for known plugins so the prompt is skipped. + // Also clear out empty/invalid values so they don't bypass the prompt. + if ( + template.role === 'backend-plugin-module' || + template.role === 'frontend-plugin-module' + ) { + const pluginPkg = (parameters.pluginPackage as string | undefined)?.trim(); + if (!pluginPkg || !isValidNpmPackageName(pluginPkg)) { + delete parameters.pluginPackage; + } else { + parameters.pluginPackage = pluginPkg; + } + + if (parameters.pluginId && !parameters.pluginPackage) { + const knownPackages = + template.role === 'backend-plugin-module' + ? knownBackendPluginPackageNameByPluginId + : knownFrontendPluginPackageNameByPluginId; + const knownPackage = knownPackages[parameters.pluginId as string]; + if (knownPackage) { + parameters.pluginPackage = knownPackage; + } + } + } + const needsAnswer = []; const prefilledAnswers = {} as PortableTemplateParams; for (const prompt of prompts) { @@ -76,15 +105,32 @@ export async function collectPortableTemplateInput( ); const answers = { + ...parameters, ...prefilledAnswers, ...promptAnswers, }; + let pluginPackage: string | undefined; + if ( + template.role === 'backend-plugin-module' || + template.role === 'frontend-plugin-module' + ) { + const knownPackages = + template.role === 'backend-plugin-module' + ? knownBackendPluginPackageNameByPluginId + : knownFrontendPluginPackageNameByPluginId; + + pluginPackage = + (answers.pluginPackage as string) ?? + knownPackages[answers.pluginId as string]; + } + const roleParams = { role: template.role, name: answers.name, pluginId: answers.pluginId, moduleId: answers.moduleId, + pluginPackage, } as PortableTemplateInputRoleParams; const packageParams = resolvePackageParams({ @@ -153,6 +199,38 @@ export function moduleIdIdPrompt(): DistinctQuestion { }; } +export function pluginPackagePrompt( + role: 'backend-plugin-module' | 'frontend-plugin-module', +): DistinctQuestion { + const knownPackages = + role === 'backend-plugin-module' + ? knownBackendPluginPackageNameByPluginId + : knownFrontendPluginPackageNameByPluginId; + + const examplePackage = + role === 'backend-plugin-module' + ? '@backstage/plugin-catalog-backend' + : '@backstage/plugin-catalog'; + + return { + type: 'input', + name: 'pluginPackage', + filter: (value: string) => value.trim(), + message: `Enter the package name of the plugin this module extends (e.g. ${examplePackage}) [required]`, + validate: (value: string) => { + if (!value) { + return 'Please enter the package name of the plugin'; + } + if (!isValidNpmPackageName(value)) { + return `Please enter a valid npm package name (e.g. ${examplePackage} or my-plugin)`; + } + return true; + }, + when: (answers: PortableTemplateParams) => + !knownPackages[answers.pluginId as string], + }; +} + export function getPromptsForRole( role: PortableTemplateRole, ): Array { @@ -170,7 +248,7 @@ export function getPromptsForRole( return [pluginIdPrompt()]; case 'frontend-plugin-module': case 'backend-plugin-module': - return [pluginIdPrompt(), moduleIdIdPrompt()]; + return [pluginIdPrompt(), moduleIdIdPrompt(), pluginPackagePrompt(role)]; default: return []; } @@ -195,3 +273,13 @@ export function ownerPrompt(): DistinctQuestion { }, }; } + +// Based on the same pattern as namePrompt/pluginIdPrompt, but extended to support npm scopes +// and additional allowed characters ('.' and '_'). Matches examples like: package-name, my.package_name, @scope/package-name, @scope/package. +const packageNamePattern = /^[a-z0-9][a-z0-9._-]*$/; +const scopedPackageNamePattern = + /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/; + +function isValidNpmPackageName(name: string) { + return packageNamePattern.test(name) || scopedPackageNamePattern.test(name); +} diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts index f279b702b1..36bb00b324 100644 --- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts +++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts @@ -80,14 +80,24 @@ describe.each([ }, ], [ - { role: 'frontend-plugin-module', pluginId: 'test1', moduleId: 'test2' }, + { + role: 'frontend-plugin-module', + pluginId: 'test1', + moduleId: 'test2', + pluginPackage: '@backstage/plugin-test1', + }, { packageName: '@internal/plugin-test1-module-test2', packagePath: 'plugins/test1-module-test2', }, ], [ - { role: 'backend-plugin-module', pluginId: 'test1', moduleId: 'test2' }, + { + role: 'backend-plugin-module', + pluginId: 'test1', + moduleId: 'test2', + pluginPackage: '@backstage/plugin-test1-backend', + }, { packageName: '@internal/plugin-test1-backend-module-test2', packagePath: 'plugins/test1-backend-module-test2', diff --git a/packages/cli-module-new/src/lib/types.ts b/packages/cli-module-new/src/lib/types.ts index 83a195a43a..6c6cd8b584 100644 --- a/packages/cli-module-new/src/lib/types.ts +++ b/packages/cli-module-new/src/lib/types.ts @@ -94,9 +94,16 @@ export type PortableTemplateInputRoleParams = pluginId: string; } | { - role: 'frontend-plugin-module' | 'backend-plugin-module'; + role: 'frontend-plugin-module'; pluginId: string; moduleId: string; + pluginPackage: string; + } + | { + role: 'backend-plugin-module'; + pluginId: string; + moduleId: string; + pluginPackage: string; }; export type PortableTemplateInput = { diff --git a/packages/cli-module-new/templates/backend-plugin-module/package.json.hbs b/packages/cli-module-new/templates/backend-plugin-module/package.json.hbs index c34f7e0646..5e21e5c2eb 100644 --- a/packages/cli-module-new/templates/backend-plugin-module/package.json.hbs +++ b/packages/cli-module-new/templates/backend-plugin-module/package.json.hbs @@ -10,7 +10,8 @@ }, "backstage": { "role": "backend-plugin-module", - "pluginId": "{{pluginId}}" + "pluginId": "{{pluginId}}", + "pluginPackage": "{{pluginPackage}}" }, "scripts": { "start": "backstage-cli package start", diff --git a/packages/cli-module-new/templates/frontend-plugin-module/package.json.hbs b/packages/cli-module-new/templates/frontend-plugin-module/package.json.hbs index d0511d96c0..24a6281ff0 100644 --- a/packages/cli-module-new/templates/frontend-plugin-module/package.json.hbs +++ b/packages/cli-module-new/templates/frontend-plugin-module/package.json.hbs @@ -11,7 +11,8 @@ }, "backstage": { "role": "frontend-plugin-module", - "pluginId": "{{pluginId}}" + "pluginId": "{{pluginId}}", + "pluginPackage": "{{pluginPackage}}" }, "sideEffects": false, "scripts": { diff --git a/packages/cli-module-new/templates/scaffolder-backend-module/package.json.hbs b/packages/cli-module-new/templates/scaffolder-backend-module/package.json.hbs index 72cb370443..bb7704964d 100644 --- a/packages/cli-module-new/templates/scaffolder-backend-module/package.json.hbs +++ b/packages/cli-module-new/templates/scaffolder-backend-module/package.json.hbs @@ -10,7 +10,8 @@ }, "backstage": { "role": "backend-plugin-module", - "pluginId": "scaffolder" + "pluginId": "scaffolder", + "pluginPackage": "@backstage/plugin-scaffolder-backend" }, "scripts": { "start": "backstage-cli package start",