Merge pull request #32627 from backstage/fix-plugipPackage-metadata
cli: Add pluginPackage support to backend-plugin-module template
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-module-maintenance': patch
|
||||
---
|
||||
|
||||
Added auto-fill of `backstage.pluginPackage` metadata for known plugins during `repo fix`.
|
||||
@@ -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.
|
||||
@@ -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}`,
|
||||
]),
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user