Merge pull request #32627 from backstage/fix-plugipPackage-metadata

cli: Add pluginPackage support to backend-plugin-module template
This commit is contained in:
Patrik Oldsberg
2026-03-17 22:18:16 +01:00
committed by GitHub
13 changed files with 406 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-maintenance': patch
---
Added auto-fill of `backstage.pluginPackage` metadata for known plugins during `repo fix`.
+6
View File
@@ -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.
+4
View File
@@ -31,3 +31,7 @@ export {
resetSecretStore,
type SecretStore,
} from './secretStore';
export {
knownBackendPluginPackageNameByPluginId,
knownFrontendPluginPackageNameByPluginId,
} from './knownPluginPackages';
@@ -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<string, string> =
Object.fromEntries(
knownBackendPluginIds.map(pluginId => [
pluginId,
`@backstage/plugin-${pluginId}-backend`,
]),
);
/**
* Maps known plugin IDs to their corresponding frontend package names.
*/
export const knownFrontendPluginPackageNameByPluginId: Record<string, string> =
Object.fromEntries(
knownFrontendPluginIds.map(pluginId => [
pluginId,
`@backstage/plugin-${pluginId}`,
]),
);
+2 -2
View File
@@ -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"
}
}
@@ -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<string | undefined> = [
'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) {
@@ -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',
});
});
});
});
@@ -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<DistinctQuestion> {
@@ -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);
}
@@ -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',
+8 -1
View File
@@ -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 = {
@@ -10,7 +10,8 @@
},
"backstage": {
"role": "backend-plugin-module",
"pluginId": "{{pluginId}}"
"pluginId": "{{pluginId}}",
"pluginPackage": "{{pluginPackage}}"
},
"scripts": {
"start": "backstage-cli package start",
@@ -11,7 +11,8 @@
},
"backstage": {
"role": "frontend-plugin-module",
"pluginId": "{{pluginId}}"
"pluginId": "{{pluginId}}",
"pluginPackage": "{{pluginPackage}}"
},
"sideEffects": false,
"scripts": {
@@ -10,7 +10,8 @@
},
"backstage": {
"role": "backend-plugin-module",
"pluginId": "scaffolder"
"pluginId": "scaffolder",
"pluginPackage": "@backstage/plugin-scaffolder-backend"
},
"scripts": {
"start": "backstage-cli package start",