Split CLI modules into separate packages

Extract each CLI module from packages/cli/src/modules/ into its own
package under packages/cli-module-*. This enables independent versioning
and clearer dependency boundaries for each CLI capability.

Module mapping:
- auth → @backstage/cli-module-auth
- build → @backstage/cli-module-build
- config → @backstage/cli-module-config
- create-github-app → @backstage/cli-module-create-github-app
- info → @backstage/cli-module-info
- lint → @backstage/cli-module-lint
- maintenance → @backstage/cli-module-maintenance
- migrate → @backstage/cli-module-migrate
- new → @backstage/cli-module-new
- test → @backstage/cli-module-test-jest
- translations → @backstage/cli-module-translations

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-13 13:43:02 +01:00
parent 18012b5802
commit a151ad0814
251 changed files with 1327 additions and 339 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+10
View File
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-cli-module-new
title: '@backstage/cli-module-new'
description: CLI module for Backstage CLI
spec:
lifecycle: experimental
type: backstage-cli-module
owner: tooling-maintainers
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@backstage/cli-module-new",
"version": "0.1.0",
"description": "CLI module for Backstage CLI",
"backstage": {
"role": "cli-module"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/cli-module-new"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/cli-node": "workspace:^"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
}
}
@@ -0,0 +1,56 @@
/*
* 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.
*/
import { createNewPackage } from '../lib/createNewPackage';
import { default as newCommand } from './new';
import type { CliCommandContext } from '@backstage/cli-node';
jest.mock('../lib/createNewPackage');
describe.each([
[undefined, undefined, undefined],
['internal', '@internal/', 'backstage-plugin-'],
['internal/', '@internal/', 'backstage-plugin-'],
['@internal', '@internal/', 'backstage-plugin-'],
['@internal/', '@internal/', 'backstage-plugin-'],
['acme-backstage', '@acme-backstage/', 'plugin-'],
['acme-backstage/', '@acme-backstage/', 'plugin-'],
['acme-backstage-plugins', '@acme-backstage-plugins/', 'plugin-'],
])('new', (scope, prefix, infix) => {
beforeEach(() => {
jest.resetAllMocks();
});
it(`should generate naming options for --scope=${scope}`, async () => {
const args = ['--skip-install'];
if (scope) {
args.push('--scope', scope);
}
const context: CliCommandContext = {
args,
info: { usage: 'backstage-cli new', name: 'new' },
};
await newCommand(context);
expect(createNewPackage).toHaveBeenCalledWith(
expect.objectContaining({
configOverrides: expect.objectContaining({
packageNamePrefix: prefix,
packageNamePluginInfix: infix,
}),
}),
);
});
});
+139
View File
@@ -0,0 +1,139 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import { createNewPackage } from '../lib/createNewPackage';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
for (const flag of ['skipInstall', 'npmRegistry', 'baseVersion']) {
if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) {
process.stderr.write(
`DEPRECATION WARNING: --${flag} is deprecated, use the kebab-case form instead\n`,
);
}
}
const {
flags: {
select,
option: rawArgOptions,
skipInstall,
scope,
npmRegistry,
baseVersion,
license,
private: isPrivate,
},
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
select: {
type: String,
description: 'Select the thing you want to be creating upfront',
},
option: {
type: [String] as const,
description: 'Pre-fill options for the creation process',
default: [] as string[],
},
skipInstall: {
type: Boolean,
description: `Skips running 'yarn install' and 'yarn lint --fix'`,
},
scope: {
type: String,
description: 'The scope to use for new packages',
},
npmRegistry: {
type: String,
description: 'The package registry to use for new packages',
},
baseVersion: {
type: String,
description:
'The version to use for any new packages (default: 0.1.0)',
},
license: {
type: String,
description:
'The license to use for any new packages (default: Apache-2.0)',
},
private: {
type: Boolean,
description: 'Mark new packages as private',
default: true,
},
},
},
undefined,
args,
);
const prefilledParams = parseParams(rawArgOptions);
let pluginInfix: string | undefined = undefined;
let packagePrefix: string | undefined = undefined;
if (scope) {
const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`;
packagePrefix = normalizedScope.includes('/')
? normalizedScope
: `${normalizedScope}/`;
pluginInfix = scope.includes('backstage') ? 'plugin-' : 'backstage-plugin-';
}
if (
isPrivate === false ||
[npmRegistry, baseVersion, license].filter(Boolean).length !== 0
) {
console.warn(
`Global template configuration via CLI flags is deprecated, see https://backstage.io/docs/cli/new for information on how to configure package templating`,
);
}
await createNewPackage({
prefilledParams,
preselectedTemplateId: select,
configOverrides: {
license,
version: baseVersion,
private: isPrivate,
publishRegistry: npmRegistry,
packageNamePrefix: packagePrefix,
packageNamePluginInfix: pluginInfix,
},
skipInstall: Boolean(skipInstall),
});
};
function parseParams(optionStrings: string[]): Record<string, string> {
const options: Record<string, string> = {};
for (const str of optionStrings) {
const [key] = str.split('=', 1);
const value = str.slice(key.length + 1);
if (!key || str[key.length] !== '=') {
throw new Error(
`Invalid option '${str}', must be of the format <key>=<value>`,
);
}
options[key] = value;
}
return options;
}
+51
View File
@@ -0,0 +1,51 @@
/*
* 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.
*/
import { createCliModule } from '@backstage/cli-node';
import { NotImplementedError } from '@backstage/errors';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['new'],
description:
'Open up an interactive guide to creating new things in your app',
execute: { loader: () => import('./commands/new') },
});
reg.addCommand({
path: ['create'],
description: 'Create a new Backstage app',
deprecated: true,
execute: async () => {
throw new NotImplementedError(
`This command has been removed, use 'backstage-cli new' instead`,
);
},
});
reg.addCommand({
path: ['create-plugin'],
description: 'Create a new Backstage plugin',
deprecated: true,
execute: async () => {
throw new NotImplementedError(
`This command has been removed, use 'backstage-cli new' instead`,
);
},
});
},
});
@@ -0,0 +1,49 @@
/*
* Copyright 2020 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 { isValidSingleOwnerId, parseOwnerIds } from './codeowners';
describe('codeowners', () => {
it('isValidSingleOwnerId', () => {
[
'@foo',
'@a-b',
'@org-a/team-a',
'@a/b',
'adam_driver+spam@deathstar.com',
].forEach(id => {
expect(isValidSingleOwnerId(id)).toBeTruthy();
});
[
'',
'@',
'@/team-a',
'@orsdsd/',
'adam_driver@deathstar',
'something',
].forEach(id => {
expect(isValidSingleOwnerId(id)).toBeFalsy();
});
});
it('parseOwnerIds', () => {
expect(parseOwnerIds('')).toBeUndefined();
expect(parseOwnerIds('@foo')).toEqual(['@foo']);
expect(parseOwnerIds(' @foo @bar/baz ')).toEqual(['@foo', '@bar/baz']);
expect(parseOwnerIds(' @foo @bar/ ')).toBeUndefined();
});
});
@@ -0,0 +1,137 @@
/*
* Copyright 2020 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 path from 'node:path';
import { targetPaths } from '@backstage/cli-common';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
const EMAIL_RE = /^[^@]+@[-.\w]+\.[-\w]+$/i;
const DEFAULT_OWNER = '@backstage/maintainers';
type CodeownersEntry = {
ownedPath: string;
ownerIds: string[];
};
export async function getCodeownersFilePath(
rootDir: string,
): Promise<string | undefined> {
const possiblePaths = [
path.join(rootDir, '.github', 'CODEOWNERS'),
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
path.join(rootDir, 'docs', 'CODEOWNERS'),
path.join(rootDir, 'CODEOWNERS'),
];
for (const p of possiblePaths) {
if (await fs.pathExists(p)) {
return p;
}
}
return undefined;
}
export function isValidSingleOwnerId(id: string): boolean {
if (!id || typeof id !== 'string') {
return false;
}
return TEAM_ID_RE.test(id) || USER_ID_RE.test(id) || EMAIL_RE.test(id);
}
export function parseOwnerIds(
spaceSeparatedOwnerIds: string | undefined,
): string[] | undefined {
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
return undefined;
}
const ids = spaceSeparatedOwnerIds.split(' ').filter(Boolean);
if (!ids.every(isValidSingleOwnerId)) {
return undefined;
}
return ids;
}
export async function addCodeownersEntry(
ownedPath: string,
ownerStr: string,
codeownersFilePath?: string,
): Promise<boolean> {
const ownerIds = parseOwnerIds(ownerStr);
if (!ownerIds || ownerIds.length === 0) {
return false;
}
let filePath = codeownersFilePath;
if (!filePath) {
filePath = await getCodeownersFilePath(targetPaths.rootDir);
if (!filePath) {
return false;
}
}
const allLines = (await fs.readFile(filePath, 'utf8')).split('\n');
// Only keep comments from the top of the file
const commentLines = [];
for (const line of allLines) {
if (line[0] !== '#') {
break;
}
commentLines.push(line);
}
const oldDeclarationEntries: CodeownersEntry[] = allLines
.filter(line => line[0] !== '#')
.map(line => line.split(/\s+/).filter(Boolean))
.filter(tokens => tokens.length >= 2)
.map(tokens => ({
ownedPath: tokens[0],
ownerIds: tokens.slice(1),
}));
const newDeclarationEntries = oldDeclarationEntries
.filter(entry => entry.ownedPath !== '*')
.concat([{ ownedPath, ownerIds }])
.sort((l1, l2) => l1.ownedPath.localeCompare(l2.ownedPath));
newDeclarationEntries.unshift({
ownedPath: '*',
ownerIds: [DEFAULT_OWNER],
});
// Calculate longest path to be able to align entries nicely
const longestOwnedPath = newDeclarationEntries.reduce(
(length, entry) => Math.max(length, entry.ownedPath.length),
0,
);
const newDeclarationLines = newDeclarationEntries.map(entry => {
const entryPath =
entry.ownedPath + ' '.repeat(longestOwnedPath - entry.ownedPath.length);
return [entryPath, ...entry.ownerIds].join(' ');
});
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
await fs.writeFile(filePath, newLines.join('\n'), 'utf8');
return true;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 * from './codeowners';
@@ -0,0 +1,56 @@
/*
* 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.
*/
import {
collectPortableTemplateInput,
loadPortableTemplate,
loadPortableTemplateConfig,
selectTemplateInteractively,
} from './preparation';
import { executePortableTemplate } from './execution';
import { PortableTemplateConfig, PortableTemplateParams } from './types';
export type CreateNewPackageOptions = {
preselectedTemplateId?: string;
configOverrides: Partial<PortableTemplateConfig>;
prefilledParams: PortableTemplateParams;
skipInstall?: boolean;
};
export async function createNewPackage(options: CreateNewPackageOptions) {
const config = await loadPortableTemplateConfig({
overrides: options.configOverrides,
});
const selectedTemplate = await selectTemplateInteractively(
config,
options.preselectedTemplateId,
);
const template = await loadPortableTemplate(selectedTemplate);
const input = await collectPortableTemplateInput({
config,
template,
prefilledParams: options.prefilledParams,
});
await executePortableTemplate({
config,
template,
input,
skipInstall: options.skipInstall,
});
}
@@ -0,0 +1,28 @@
/*
* 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.
*/
export const defaultTemplates = [
'@backstage/cli/templates/frontend-plugin',
'@backstage/cli/templates/backend-plugin',
'@backstage/cli/templates/backend-plugin-module',
'@backstage/cli/templates/plugin-web-library',
'@backstage/cli/templates/plugin-node-library',
'@backstage/cli/templates/plugin-common-library',
'@backstage/cli/templates/web-library',
'@backstage/cli/templates/node-library',
'@backstage/cli/templates/catalog-provider-module',
'@backstage/cli/templates/scaffolder-backend-module',
];
@@ -0,0 +1,112 @@
/*
* 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.
*/
import handlebars from 'handlebars';
import { PortableTemplateParams } from '../types';
import camelCase from 'lodash/camelCase';
import kebabCase from 'lodash/kebabCase';
import lowerCase from 'lodash/lowerCase';
import snakeCase from 'lodash/snakeCase';
import startCase from 'lodash/startCase';
import upperCase from 'lodash/upperCase';
import upperFirst from 'lodash/upperFirst';
import lowerFirst from 'lodash/lowerFirst';
import { Lockfile } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { createPackageVersionProvider } from '../version';
import { hasBackstageYarnPlugin } from '@backstage/cli-node';
const builtInHelpers = {
camelCase,
kebabCase,
lowerCase,
snakeCase,
startCase,
upperCase,
upperFirst,
lowerFirst,
};
type CreatePortableTemplaterOptions = {
values?: PortableTemplateParams;
templatedValues?: Record<string, string>;
};
export class PortableTemplater {
static async create(options: CreatePortableTemplaterOptions = {}) {
let lockfile: Lockfile | undefined;
try {
lockfile = await Lockfile.load(targetPaths.resolveRoot('yarn.lock'));
} catch {
/* ignored */
}
const yarnPluginEnabled = await hasBackstageYarnPlugin();
const versionProvider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: yarnPluginEnabled,
});
const templater = new PortableTemplater(
{
versionQuery(name: string, versionHint: string | unknown) {
return versionProvider(
name,
typeof versionHint === 'string' ? versionHint : undefined,
);
},
},
options.values ?? {},
);
if (options.templatedValues) {
templater.appendTemplatedValues(options.templatedValues);
}
return templater;
}
readonly #templater: typeof handlebars;
#values: PortableTemplateParams;
private constructor(
helpers: handlebars.HelperDeclareSpec,
values: PortableTemplateParams,
) {
this.#templater = handlebars.create();
this.#templater.registerHelper(builtInHelpers);
if (helpers) {
this.#templater.registerHelper(helpers);
}
this.#values = values;
}
template(content: string): string {
return this.#templater.compile(content, {
strict: true,
})(this.#values);
}
appendTemplatedValues(record: Record<string, string>): void {
const newValues = Object.fromEntries(
Object.entries(record).map(([key, value]) => [key, this.template(value)]),
);
this.#values = { ...this.#values, ...newValues };
}
}
@@ -0,0 +1,99 @@
/*
* 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.
*/
import { assertError } from '@backstage/errors';
import { addCodeownersEntry } from '../codeowners';
import { Task } from '../tasks';
import {
PortableTemplate,
PortableTemplateConfig,
PortableTemplateInput,
} from '../types';
import { installNewPackage } from './installNewPackage';
import { writeTemplateContents } from './writeTemplateContents';
import { run } from '@backstage/cli-common';
type ExecuteNewTemplateOptions = {
config: PortableTemplateConfig;
template: PortableTemplate;
input: PortableTemplateInput;
skipInstall?: boolean;
};
export async function executePortableTemplate(
options: ExecuteNewTemplateOptions,
) {
const { template, input } = options;
let modified = false;
try {
const { targetDir } = await Task.forItem(
'templating',
input.packagePath,
() => writeTemplateContents(template, input),
);
modified = true;
await installNewPackage(input);
if (input.owner) {
await addCodeownersEntry(targetDir, input.owner);
}
if (!options.skipInstall) {
for (const command of [
['yarn', 'install'],
['yarn', 'lint', '--fix'],
]) {
const commandStr = command.join(' ');
try {
await Task.forItem('executing', commandStr, async () => {
await run(command, {
cwd: targetDir,
stdio: 'ignore',
}).waitForExit();
});
} catch (error) {
assertError(error);
Task.error(
`Warning: Failed to execute command '${commandStr}', ${error}`,
);
}
}
}
Task.log();
Task.log(`🎉 Successfully created ${template.name}`);
Task.log();
} catch (error) {
assertError(error);
Task.error(error.message);
if (modified) {
Task.log('It seems that something went wrong in the creation process 🤔');
Task.log();
Task.log(
'We have left the changes that were made intact in case you want to',
);
Task.log(
'continue manually, but you can also revert the changes and try again.',
);
Task.error(`🔥 Failed to create ${template.name}!`);
}
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { executePortableTemplate } from './executePortableTemplate';
@@ -0,0 +1,148 @@
/*
* Copyright 2024 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 upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import { targetPaths } from '@backstage/cli-common';
import { Task } from '../tasks';
import { PortableTemplateInput } from '../types';
export async function installNewPackage(input: PortableTemplateInput) {
switch (input.roleParams.role) {
case 'web-library':
case 'node-library':
case 'common-library':
case 'plugin-web-library':
case 'plugin-node-library':
case 'plugin-common-library':
return; // No installation action needed for library packages
case 'frontend-plugin':
await addDependency(input, 'packages/app/package.json');
await tryAddFrontendLegacy(input);
return;
case 'frontend-plugin-module':
await addDependency(input, 'packages/app/package.json');
return;
case 'backend-plugin':
await addDependency(input, 'packages/backend/package.json');
await tryAddBackend(input);
return;
case 'backend-plugin-module':
await addDependency(input, 'packages/backend/package.json');
await tryAddBackend(input);
return;
default:
throw new Error(
`Unsupported role ${(input.roleParams as { role: string }).role}`,
);
}
}
async function addDependency(input: PortableTemplateInput, path: string) {
const pkgJsonPath = targetPaths.resolveRoot(path);
const pkgJson = await fs.readJson(pkgJsonPath).catch(error => {
if (error.code === 'ENOENT') {
return undefined;
}
throw error;
});
if (!pkgJson) {
return;
}
try {
pkgJson.dependencies = {
...pkgJson.dependencies,
[input.packageName]: `workspace:^`,
};
await fs.writeJson(path, pkgJson, { spaces: 2 });
} catch (error) {
throw new Error(`Failed to add package dependencies, ${error}`);
}
}
async function tryAddFrontendLegacy(input: PortableTemplateInput) {
const { roleParams } = input;
if (roleParams.role !== 'frontend-plugin') {
throw new Error(
'add-frontend-legacy can only be used for frontend plugins',
);
}
const appDefinitionPath = targetPaths.resolveRoot('packages/app/src/App.tsx');
if (!(await fs.pathExists(appDefinitionPath))) {
return;
}
await Task.forItem('app', 'adding import', async () => {
const content = await fs.readFile(appDefinitionPath, 'utf8');
const revLines = content.split('\n').reverse();
const lastImportIndex = revLines.findIndex(line =>
line.match(/ from ("|').*("|')/),
);
const lastRouteIndex = revLines.findIndex(line =>
line.match(/<\/FlatRoutes/),
);
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
const extensionName = upperFirst(`${camelCase(roleParams.pluginId)}Page`);
const importLine = `import { ${extensionName} } from '${input.packageName}';`;
if (!content.includes(importLine)) {
revLines.splice(lastImportIndex, 0, importLine);
}
const componentLine = `<Route path="/${roleParams.pluginId}" element={<${extensionName} />} />`;
if (!content.includes(componentLine)) {
const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine);
}
const newContent = revLines.reverse().join('\n');
await fs.writeFile(appDefinitionPath, newContent, 'utf8');
}
});
}
async function tryAddBackend(input: PortableTemplateInput) {
const backendIndexPath = targetPaths.resolveRoot(
'packages/backend/src/index.ts',
);
if (!(await fs.pathExists(backendIndexPath))) {
return;
}
await Task.forItem('backend', `adding ${input.packageName}`, async () => {
const content = await fs.readFile(backendIndexPath, 'utf8');
const lines = content.split('\n');
const backendAddLine = `backend.add(import('${input.packageName}'));`;
const backendStartIndex = lines.findIndex(line =>
line.match(/backend.start/),
);
if (backendStartIndex !== -1) {
const [indentation] = lines[backendStartIndex].match(/^\s*/)!;
lines.splice(backendStartIndex, 0, `${indentation}${backendAddLine}`);
const newContent = lines.join('\n');
await fs.writeFile(backendIndexPath, newContent, 'utf8');
}
});
}
@@ -0,0 +1,107 @@
/*
* Copyright 2021 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 { relative as relativePath } from 'node:path';
import { writeTemplateContents } from './writeTemplateContents';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
const mockDir = createMockDirectory();
overrideTargetPaths(mockDir.path);
const baseConfig = {
version: '0.1.0',
license: 'Apache-2.0',
private: true,
};
describe('writeTemplateContents', () => {
beforeEach(() => {
mockDir.clear();
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: { packages: ['packages/*', 'plugins/*'] },
}),
});
jest.resetAllMocks();
});
it('should write an empty template', async () => {
const { targetDir } = await writeTemplateContents(
{
name: 'test',
files: [],
role: 'frontend-plugin',
values: {},
},
{
...baseConfig,
roleParams: { role: 'frontend-plugin', pluginId: 'test' },
packageName: '@internal/plugin-test',
packagePath: 'plugins/plugin-test',
},
);
expect(relativePath(mockDir.path, targetDir)).toBe('plugins/plugin-test');
expect(mockDir.content()).toEqual({
'package.json': JSON.stringify({
workspaces: { packages: ['packages/*', 'plugins/*'] },
}),
});
});
it('should write template with various files', async () => {
await writeTemplateContents(
{
name: 'test',
files: [
{
content: 'test',
path: 'test.txt',
},
{
content: 'id={{ pluginId}}',
path: 'plugin.txt',
syntax: 'handlebars',
},
{
content: '{"x":1}',
path: 'test.json',
},
],
role: 'frontend-plugin',
values: {},
},
{
...baseConfig,
roleParams: { role: 'frontend-plugin', pluginId: 'test' },
packageName: '@internal/plugin-test',
packagePath: 'out',
},
);
expect(mockDir.content()).toEqual({
'package.json': JSON.stringify({
workspaces: { packages: ['packages/*', 'plugins/*'] },
}),
out: {
'test.txt': 'test',
'plugin.txt': 'id=test',
'test.json': '{"x":1}',
},
});
});
});
@@ -0,0 +1,152 @@
/*
* Copyright 2021 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 { dirname, resolve as resolvePath } from 'node:path';
import { PortableTemplate, PortableTemplateInput } from '../types';
import { ForwardedError, InputError } from '@backstage/errors';
import { isMonoRepo as getIsMonoRepo } from '@backstage/cli-node';
import { PortableTemplater } from './PortableTemplater';
import { isChildPath, targetPaths } from '@backstage/cli-common';
export async function writeTemplateContents(
template: PortableTemplate,
input: PortableTemplateInput,
): Promise<{ targetDir: string }> {
const targetDir = targetPaths.resolveRoot(input.packagePath);
if (await fs.pathExists(targetDir)) {
throw new InputError(`Package '${input.packagePath}' already exists`);
}
try {
const isMonoRepo = await getIsMonoRepo();
const { role, ...roleValues } = input.roleParams;
const templater = await PortableTemplater.create({
values: {
...roleValues,
packageName: input.packageName,
},
templatedValues: template.values,
});
if (!isMonoRepo) {
await fs.writeJson(
resolvePath(targetDir, 'tsconfig.json'),
{
extends: '@backstage/cli/config/tsconfig.json',
include: ['src', 'dev', 'migrations'],
exclude: ['node_modules'],
compilerOptions: {
outDir: 'dist-types',
rootDir: '.',
},
},
{ spaces: 2 },
);
}
for (const file of template.files) {
const destPath = resolvePath(targetDir, templater.template(file.path));
if (!isChildPath(targetDir, destPath)) {
throw new Error(
`Path ${destPath} is outside of target directory ${targetDir}`,
);
}
await fs.ensureDir(dirname(destPath));
let content =
file.syntax === 'handlebars'
? templater.template(file.content)
: file.content;
// Automatically inject input values into package.json
if (file.path === 'package.json') {
try {
content = injectPackageJsonInput(input, content);
} catch (error) {
throw new ForwardedError(
'Failed to transform templated package.json',
error,
);
}
}
await fs.writeFile(destPath, content).catch(error => {
throw new ForwardedError(`Failed to copy file to ${destPath}`, error);
});
}
return { targetDir };
} catch (error) {
await fs.rm(targetDir, { recursive: true, force: true, maxRetries: 10 });
throw error;
}
}
export function injectPackageJsonInput(
input: PortableTemplateInput,
content: string,
) {
const pkgJson = JSON.parse(content);
const toAdd = new Array<[name: string, value: unknown]>();
if (pkgJson.version) {
pkgJson.version = input.version;
} else {
toAdd.push(['version', input.version]);
}
if (pkgJson.license) {
pkgJson.license = input.license;
} else {
toAdd.push(['license', input.license]);
}
if (input.private) {
if (pkgJson.private === false) {
pkgJson.private = true;
} else if (!pkgJson.private) {
toAdd.push(['private', true]);
}
} else {
delete pkgJson.private;
}
if (input.publishRegistry) {
if (pkgJson.publishConfig) {
pkgJson.publishConfig = {
...pkgJson.publishConfig,
registry: input.publishRegistry,
};
} else {
toAdd.push(['publishConfig', { registry: input.publishRegistry }]);
}
}
const entries = Object.entries(pkgJson);
const nameIndex = entries.findIndex(([name]) => name === 'name');
if (nameIndex === -1) {
throw new Error('templated package.json does not contain a name field');
}
entries.splice(nameIndex + 1, 0, ...toAdd);
return JSON.stringify(Object.fromEntries(entries), null, 2);
}
@@ -0,0 +1,152 @@
/*
* Copyright 2024 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 inquirer from 'inquirer';
import { PortableTemplateConfig } from '../types';
import { collectPortableTemplateInput } from './collectPortableTemplateInput';
import { withLogCollector } from '@backstage/test-utils';
describe('collectTemplateParams', () => {
const baseOptions = {
config: {
isUsingDefaultTemplates: false,
templatePointers: [],
version: '0.1.0',
license: 'Apache-2.0',
private: true,
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'plugin-',
} satisfies PortableTemplateConfig,
template: {
name: 'test',
role: 'frontend-plugin' as const,
files: [],
values: {},
},
prefilledParams: {},
};
beforeEach(() => {
jest.resetAllMocks();
});
it('should prompt for missing parameters', async () => {
jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ pluginId: 'other' });
await expect(
collectPortableTemplateInput({
...baseOptions,
prefilledParams: {},
}),
).resolves.toEqual({
roleParams: {
role: 'frontend-plugin',
pluginId: 'other',
},
owner: undefined,
version: '0.1.0',
license: 'Apache-2.0',
private: true,
packageName: '@internal/plugin-other',
packagePath: 'plugins/other',
});
});
it('should pick up prefilled parameters', async () => {
await expect(
collectPortableTemplateInput({
...baseOptions,
prefilledParams: {
pluginId: 'test1',
owner: 'me',
},
}),
).resolves.toEqual({
roleParams: {
role: 'frontend-plugin',
pluginId: 'test1',
},
owner: 'me',
version: '0.1.0',
license: 'Apache-2.0',
private: true,
packageName: '@internal/plugin-test1',
packagePath: 'plugins/test1',
});
});
it('should pick up template values', async () => {
await expect(
collectPortableTemplateInput({
...baseOptions,
template: {
...baseOptions.template,
values: {
pluginId: 'test2',
owner: 'me',
},
},
}),
).resolves.toEqual({
roleParams: {
role: 'frontend-plugin',
pluginId: 'test2',
},
owner: 'me',
version: '0.1.0',
license: 'Apache-2.0',
private: true,
packageName: '@internal/plugin-test2',
packagePath: 'plugins/test2',
});
});
it('should map deprecated id param to pluginId', async () => {
const logs = await withLogCollector(async () => {
await expect(
collectPortableTemplateInput({
...baseOptions,
config: {
...baseOptions.config,
isUsingDefaultTemplates: true,
},
prefilledParams: {
id: 'test3',
owner: 'me',
},
}),
).resolves.toEqual({
roleParams: {
role: 'frontend-plugin',
pluginId: 'test3',
},
owner: 'me',
version: '0.1.0',
license: 'Apache-2.0',
private: true,
packageName: '@internal/plugin-test3',
packagePath: 'plugins/test3',
});
});
expect(logs).toEqual({
error: [],
log: [],
warn: [
`DEPRECATION WARNING: The 'id' parameter is deprecated, use 'pluginId' instead`,
],
});
});
});
@@ -0,0 +1,196 @@
/*
* 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.
*/
import inquirer, { DistinctQuestion } from 'inquirer';
import { getCodeownersFilePath, parseOwnerIds } from '../codeowners';
import { targetPaths } from '@backstage/cli-common';
import {
PortableTemplateConfig,
PortableTemplateInput,
PortableTemplateInputRoleParams,
PortableTemplateParams,
PortableTemplateRole,
} from '../types';
import { PortableTemplate } from '../types';
import { resolvePackageParams } from './resolvePackageParams';
type CollectTemplateParamsOptions = {
config: PortableTemplateConfig;
template: PortableTemplate;
prefilledParams: PortableTemplateParams;
};
export async function collectPortableTemplateInput(
options: CollectTemplateParamsOptions,
): Promise<PortableTemplateInput> {
const { config, template, prefilledParams } = options;
const codeOwnersFilePath = await getCodeownersFilePath(targetPaths.rootDir);
const prompts = getPromptsForRole(template.role);
if (codeOwnersFilePath) {
prompts.push(ownerPrompt());
}
const deprecatedParams: PortableTemplateParams = {};
if (config.isUsingDefaultTemplates && prefilledParams.id) {
console.warn(
`DEPRECATION WARNING: The 'id' parameter is deprecated, use 'pluginId' instead`,
);
deprecatedParams.pluginId = prefilledParams.id;
}
const parameters = {
...template.values,
...prefilledParams,
...deprecatedParams,
};
const needsAnswer = [];
const prefilledAnswers = {} as PortableTemplateParams;
for (const prompt of prompts) {
if (prompt.name && parameters[prompt.name] !== undefined) {
prefilledAnswers[prompt.name] = parameters[prompt.name];
} else {
needsAnswer.push(prompt);
}
}
const promptAnswers = await inquirer.prompt<PortableTemplateParams>(
needsAnswer,
);
const answers = {
...prefilledAnswers,
...promptAnswers,
};
const roleParams = {
role: template.role,
name: answers.name,
pluginId: answers.pluginId,
moduleId: answers.moduleId,
} as PortableTemplateInputRoleParams;
const packageParams = resolvePackageParams({
roleParams,
packagePrefix: config.packageNamePrefix,
pluginInfix: config.packageNamePluginInfix,
});
return {
roleParams,
owner: answers.owner as string | undefined,
license: config.license,
version: config.version,
private: config.private,
publishRegistry: config.publishRegistry,
packageName: packageParams.packageName,
packagePath: packageParams.packagePath,
};
}
export function namePrompt(): DistinctQuestion {
return {
type: 'input',
name: 'name',
message: 'Enter the name of the package, without scope [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the name of the package';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Package names must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
};
}
export function pluginIdPrompt(): DistinctQuestion {
return {
type: 'input',
name: 'pluginId',
message: 'Enter the ID of the plugin [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the ID of the plugin';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
};
}
export function moduleIdIdPrompt(): DistinctQuestion {
return {
type: 'input',
name: 'moduleId',
message: 'Enter the ID of the module [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the ID of the module';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Module IDs must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
};
}
export function getPromptsForRole(
role: PortableTemplateRole,
): Array<DistinctQuestion> {
switch (role) {
case 'web-library':
case 'node-library':
case 'common-library':
return [namePrompt()];
case 'plugin-web-library':
case 'plugin-node-library':
case 'plugin-common-library':
case 'frontend-plugin':
case 'backend-plugin':
return [pluginIdPrompt()];
case 'frontend-plugin-module':
case 'backend-plugin-module':
return [pluginIdPrompt(), moduleIdIdPrompt()];
default:
return [];
}
}
export function ownerPrompt(): DistinctQuestion {
return {
type: 'input',
name: 'owner',
message: 'Enter an owner to add to CODEOWNERS [optional]',
validate: (value: string) => {
if (!value) {
return true;
}
const ownerIds = parseOwnerIds(value);
if (!ownerIds) {
return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).';
}
return true;
},
};
}
@@ -0,0 +1,20 @@
/*
* 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.
*/
export { collectPortableTemplateInput } from './collectPortableTemplateInput';
export { loadPortableTemplateConfig } from './loadPortableTemplateConfig';
export { selectTemplateInteractively } from './selectTemplateInteractively';
export { loadPortableTemplate } from './loadPortableTemplate';
@@ -0,0 +1,110 @@
/*
* 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.
*/
import { createMockDirectory } from '@backstage/backend-test-utils';
import { loadPortableTemplate } from './loadPortableTemplate';
import { TEMPLATE_FILE_NAME } from '../types';
describe('loadTemplate', () => {
const mockDir = createMockDirectory();
afterEach(() => {
mockDir.clear();
});
it('should load a valid template', async () => {
mockDir.setContent({
'path/to': {
[TEMPLATE_FILE_NAME]: `
name: template1
role: frontend-plugin
values:
foo: bar
`,
},
'path/to/hello.txt': 'hello world',
});
await expect(
loadPortableTemplate({
name: 'template1',
target: mockDir.resolve('path/to', TEMPLATE_FILE_NAME),
}),
).resolves.toEqual({
name: 'template1',
role: 'frontend-plugin',
files: [{ path: 'hello.txt', content: 'hello world' }],
values: { foo: 'bar' },
});
});
it('should throw an error if template file does not exist', async () => {
mockDir.setContent({});
await expect(
loadPortableTemplate({
name: 'template1',
target: mockDir.resolve('path/to/template1.yaml'),
}),
).rejects.toThrow(
/^Failed to load template definition from '.*'; caused by Error: ENOENT/,
);
});
it('should throw an error if template definition is invalid', async () => {
mockDir.setContent({
'path/to/template1.yaml': `invalid: definition`,
});
await expect(
loadPortableTemplate({
name: 'template1',
target: mockDir.resolve('path/to/template1.yaml'),
}),
).rejects.toThrow(
/Invalid template definition at '.*'; caused by Validation error/,
);
});
it('should throw an error if target is a remote URL', async () => {
await expect(
loadPortableTemplate({
name: 'template1',
target: 'http://example.com',
}),
).rejects.toThrow('Remote templates are not supported yet');
});
it('should throw an error if the package role is invalid', async () => {
mockDir.setContent({
'path/to/template1.yaml': `
name: x
role: invalid-role
`,
});
await expect(
loadPortableTemplate({
name: 'template1',
target: mockDir.resolve('path/to/template1.yaml'),
}),
).rejects.toThrow(
`Invalid template definition at '${mockDir.resolve(
'path/to/template1.yaml',
)}'; caused by Validation error: Invalid enum value`,
);
});
});
@@ -0,0 +1,109 @@
/*
* 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.
*/
import { z } from 'zod';
import fs from 'fs-extra';
import recursiveReaddir from 'recursive-readdir';
import { resolve as resolvePath, relative as relativePath } from 'node:path';
import { dirname } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { targetPaths } from '@backstage/cli-common';
import {
PortableTemplateFile,
PortableTemplatePointer,
TEMPLATE_ROLES,
} from '../types';
import { PortableTemplate } from '../types';
import { ForwardedError } from '@backstage/errors';
import { fromZodError } from 'zod-validation-error/v3';
const templateDefinitionSchema = z
.object({
name: z.string(),
role: z.enum(TEMPLATE_ROLES),
description: z.string().optional(),
values: z.record(z.string()).optional(),
})
.strict();
export async function loadPortableTemplate(
pointer: PortableTemplatePointer,
): Promise<PortableTemplate> {
if (pointer.target.match(/https?:\/\//)) {
throw new Error('Remote templates are not supported yet');
}
const templateContent = await fs
.readFile(targetPaths.resolveRoot(pointer.target), 'utf-8')
.catch(error => {
throw new ForwardedError(
`Failed to load template definition from '${pointer.target}'`,
error,
);
});
const rawTemplate = parseYaml(templateContent);
const parsed = templateDefinitionSchema.safeParse(rawTemplate);
if (!parsed.success) {
throw new ForwardedError(
`Invalid template definition at '${pointer.target}'`,
fromZodError(parsed.error),
);
}
const { role, values = {} } = parsed.data;
const templatePath = resolvePath(dirname(pointer.target));
const filePaths = await recursiveReaddir(templatePath).catch(error => {
throw new ForwardedError(
`Failed to load template contents from '${templatePath}'`,
error,
);
});
const loadedFiles = new Array<PortableTemplateFile>();
for (const filePath of filePaths) {
const path = relativePath(templatePath, filePath);
if (filePath === pointer.target) {
continue;
}
const content = await fs.readFile(filePath, 'utf-8').catch(error => {
throw new ForwardedError(
`Failed to load file contents from '${path}'`,
error,
);
});
if (path.endsWith('.hbs')) {
loadedFiles.push({
path: path.slice(0, -4),
content,
syntax: 'handlebars',
});
} else {
loadedFiles.push({ path, content });
}
}
return {
name: pointer.name,
role,
files: loadedFiles,
values,
};
}
@@ -0,0 +1,339 @@
/*
* 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.
*/
import { realpathSync } from 'node:fs';
import { loadPortableTemplateConfig } from './loadPortableTemplateConfig';
import { defaultTemplates } from '../defaultTemplates';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { TEMPLATE_FILE_NAME } from '../types';
import { basename } from 'node:path';
describe('loadPortableTemplateConfig', () => {
const mockDir = createMockDirectory();
afterEach(() => {
mockDir.clear();
});
it('should load configuration from package.json', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['./path/to/template1'],
globals: {
license: 'MIT',
private: true,
namePrefix: '@acme/',
namePluginInfix: 'backstage-plugin-',
},
},
},
},
}),
'path/to/template1': {
[TEMPLATE_FILE_NAME]: 'name: template1\nrole: frontend-plugin\n',
},
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).resolves.toEqual({
isUsingDefaultTemplates: false,
templatePointers: [
{
name: 'template1',
target: mockDir.resolve('path/to/template1', TEMPLATE_FILE_NAME),
},
],
license: 'MIT',
private: true,
version: '0.1.0',
packageNamePrefix: '@acme/',
packageNamePluginInfix: 'backstage-plugin-',
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
overrides: {
license: 'nope',
private: false,
},
}),
).resolves.toEqual({
isUsingDefaultTemplates: false,
templatePointers: [
{
name: 'template1',
target: mockDir.resolve('path/to/template1', TEMPLATE_FILE_NAME),
},
],
license: 'nope',
version: '0.1.0',
private: false,
packageNamePrefix: '@acme/',
packageNamePluginInfix: 'backstage-plugin-',
});
});
it('should support pointing to built-in templates', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['@my/package/templates/plugin', 'my-package'],
globals: {
license: 'MIT',
private: true,
namePrefix: '@acme/',
namePluginInfix: 'backstage-plugin-',
},
},
},
},
}),
node_modules: {
'@my': {
package: {
templates: {
plugin: {
[TEMPLATE_FILE_NAME]:
'name: frontend-plugin\nrole: frontend-plugin\n',
},
},
},
},
'my-package': {
[TEMPLATE_FILE_NAME]: 'name: backend-plugin\nrole: backend-plugin\n',
},
},
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).resolves.toEqual({
isUsingDefaultTemplates: false,
templatePointers: [
{
name: 'frontend-plugin',
target: realpathSync(
mockDir.resolve(
'node_modules/@my/package/templates/plugin',
TEMPLATE_FILE_NAME,
),
),
},
{
name: 'backend-plugin',
target: realpathSync(
mockDir.resolve('node_modules/my-package', TEMPLATE_FILE_NAME),
),
},
],
license: 'MIT',
private: true,
version: '0.1.0',
packageNamePrefix: '@acme/',
packageNamePluginInfix: 'backstage-plugin-',
});
});
it('should use default templates if none are specified', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
globals: {
license: 'MIT',
private: true,
},
},
},
},
}),
node_modules: Object.fromEntries(
defaultTemplates.map(t => [
t,
{ [TEMPLATE_FILE_NAME]: `name: ${basename(t)}\nrole: web-library\n` },
]),
),
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).resolves.toEqual({
isUsingDefaultTemplates: true,
templatePointers: defaultTemplates.map(t => ({
name: basename(t),
target: realpathSync(
mockDir.resolve(`node_modules/${t}`, TEMPLATE_FILE_NAME),
),
})),
license: 'MIT',
private: true,
version: '0.1.0',
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'backstage-plugin-',
});
});
it('should reject templates with conflicting names', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['./template1', './template2'],
},
},
},
}),
template1: {
[TEMPLATE_FILE_NAME]: 'name: test\nrole: frontend-plugin\n',
},
template2: {
[TEMPLATE_FILE_NAME]: 'name: test\nrole: backend-plugin\n',
},
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).rejects.toThrow(
`Invalid template configuration, received conflicting template name 'test' from './template1' and './template2'`,
);
});
it('should throw an error if package.json is invalid', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: 'invalid',
},
},
},
}),
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).rejects.toThrow(
/^Failed to load templating configuration from '.*'; caused by Validation error: Expected array/,
);
});
it('should throw an error if built-in template does not exist', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['./invalid'],
},
},
},
}),
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).rejects.toThrow(
`Failed to load template definition '.\/invalid'; caused by Error: ENOENT`,
);
});
it('should throw an error if template point is absolute', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: ['/invalid'],
},
},
},
}),
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).rejects.toThrow(
"Failed to load template definition '/invalid'; caused by Error: Template target may not be an absolute path",
);
});
it('should handle missing backstage.new configuration', async () => {
mockDir.setContent({
'package.json': JSON.stringify({}),
node_modules: Object.fromEntries(
defaultTemplates.map(t => [
t,
{ [TEMPLATE_FILE_NAME]: `name: ${basename(t)}\nrole: web-library\n` },
]),
),
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).resolves.toEqual({
isUsingDefaultTemplates: true,
templatePointers: expect.any(Array),
license: 'Apache-2.0',
version: '0.1.0',
private: true,
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'backstage-plugin-',
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
overrides: {
license: 'nope',
},
}),
).resolves.toEqual({
isUsingDefaultTemplates: true,
templatePointers: expect.any(Array),
license: 'nope',
version: '0.1.0',
private: true,
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'backstage-plugin-',
});
});
});
@@ -0,0 +1,195 @@
/*
* 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.
*/
import fs from 'fs-extra';
import { resolve as resolvePath, dirname, isAbsolute } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { defaultTemplates } from '../defaultTemplates';
import {
PortableTemplateConfig,
PortableTemplatePointer,
TEMPLATE_FILE_NAME,
} from '../types';
import { parse as parseYaml } from 'yaml';
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error/v3';
import { ForwardedError } from '@backstage/errors';
const defaults = {
license: 'Apache-2.0',
version: '0.1.0',
private: true,
publishRegistry: undefined,
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'plugin-',
};
const newConfigSchema = z
.object({
templates: z.array(z.string()).optional(),
globals: z
.object({
license: z.string().optional(),
version: z.string().optional(),
private: z.boolean().optional(),
publishRegistry: z.string().optional(),
namePrefix: z.string().optional(),
namePluginInfix: z.string().optional(),
})
.optional(),
})
.strict();
const pkgJsonWithNewConfigSchema = z.object({
backstage: z
.object({
cli: z
.object({
new: newConfigSchema.optional(),
})
.optional(),
})
.optional(),
});
type LoadConfigOptions = {
packagePath?: string;
overrides?: Partial<PortableTemplateConfig>;
};
function computePackageNamePluginInfix(
packageNamePrefix: string,
namePluginInfix?: string,
) {
const packageNamePluginInfix =
namePluginInfix ??
(packageNamePrefix.includes('backstage')
? defaults.packageNamePluginInfix
: 'backstage-plugin-');
return {
packageNamePluginInfix,
};
}
export async function loadPortableTemplateConfig(
options: LoadConfigOptions = {},
): Promise<PortableTemplateConfig> {
const { overrides = {} } = options;
const pkgPath =
options.packagePath ?? targetPaths.resolveRoot('package.json');
const pkgJson = await fs.readJson(pkgPath);
const parsed = pkgJsonWithNewConfigSchema.safeParse(pkgJson);
if (!parsed.success) {
throw new ForwardedError(
`Failed to load templating configuration from '${pkgPath}'`,
fromZodError(parsed.error),
);
}
const config = parsed.data.backstage?.cli?.new;
const basePath = dirname(pkgPath);
const templatePointerEntries = await Promise.all(
(config?.templates ?? defaultTemplates).map(async rawPointer => {
try {
const templatePath = resolveLocalTemplatePath(rawPointer, basePath);
const pointer = await peekLocalTemplateDefinition(templatePath);
return { pointer, rawPointer };
} catch (error) {
throw new ForwardedError(
`Failed to load template definition '${rawPointer}'`,
error,
);
}
}),
);
const templateNameConflicts = new Map<string, string>();
for (const { pointer, rawPointer } of templatePointerEntries) {
const conflict = templateNameConflicts.get(pointer.name);
if (conflict) {
throw new Error(
`Invalid template configuration, received conflicting template name '${pointer.name}' from '${conflict}' and '${rawPointer}'`,
);
}
templateNameConflicts.set(pointer.name, rawPointer);
}
const packageNamePrefix =
overrides.packageNamePrefix ??
config?.globals?.namePrefix ??
defaults.packageNamePrefix;
const { packageNamePluginInfix } = computePackageNamePluginInfix(
packageNamePrefix,
overrides.packageNamePluginInfix ?? config?.globals?.namePluginInfix,
);
return {
isUsingDefaultTemplates: !config?.templates,
templatePointers: templatePointerEntries.map(({ pointer }) => pointer),
license: overrides.license ?? config?.globals?.license ?? defaults.license,
version: overrides.version ?? config?.globals?.version ?? defaults.version,
private: overrides.private ?? config?.globals?.private ?? defaults.private,
publishRegistry:
overrides.publishRegistry ??
config?.globals?.publishRegistry ??
defaults.publishRegistry,
packageNamePrefix,
packageNamePluginInfix,
};
}
function resolveLocalTemplatePath(pointer: string, basePath: string): string {
if (isAbsolute(pointer)) {
throw new Error(`Template target may not be an absolute path`);
}
if (pointer.startsWith('.')) {
return resolvePath(basePath, pointer, TEMPLATE_FILE_NAME);
}
return require.resolve(`${pointer}/${TEMPLATE_FILE_NAME}`, {
paths: [basePath],
});
}
const partialTemplateDefinitionSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
async function peekLocalTemplateDefinition(
target: string,
): Promise<PortableTemplatePointer> {
const content = await fs.readFile(target, 'utf8');
const rawTemplate = parseYaml(content);
const parsed = partialTemplateDefinitionSchema.safeParse(rawTemplate);
if (!parsed.success) {
throw fromZodError(parsed.error);
}
return {
name: parsed.data.name,
description: parsed.data.description,
target,
};
}
@@ -0,0 +1,99 @@
/*
* Copyright 2024 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 { resolvePackageParams } from './resolvePackageParams';
describe.each([
[
{ role: 'web-library', name: 'test' },
{
packageName: '@internal/test',
packagePath: 'packages/test',
},
],
[
{ role: 'node-library', name: 'test' },
{
packageName: '@internal/test',
packagePath: 'packages/test',
},
],
[
{ role: 'common-library', name: 'test' },
{
packageName: '@internal/test',
packagePath: 'packages/test',
},
],
[
{ role: 'plugin-web-library', pluginId: 'test' },
{
packageName: '@internal/plugin-test-react',
packagePath: 'plugins/test-react',
},
],
[
{ role: 'plugin-node-library', pluginId: 'test' },
{
packageName: '@internal/plugin-test-node',
packagePath: 'plugins/test-node',
},
],
[
{ role: 'plugin-common-library', pluginId: 'test' },
{
packageName: '@internal/plugin-test-common',
packagePath: 'plugins/test-common',
},
],
[
{ role: 'frontend-plugin', pluginId: 'test' },
{
packageName: '@internal/plugin-test',
packagePath: 'plugins/test',
},
],
[
{ role: 'backend-plugin', pluginId: 'test' },
{
packageName: '@internal/plugin-test-backend',
packagePath: 'plugins/test-backend',
},
],
[
{ role: 'frontend-plugin-module', pluginId: 'test1', moduleId: 'test2' },
{
packageName: '@internal/plugin-test1-module-test2',
packagePath: 'plugins/test1-module-test2',
},
],
[
{ role: 'backend-plugin-module', pluginId: 'test1', moduleId: 'test2' },
{
packageName: '@internal/plugin-test1-backend-module-test2',
packagePath: 'plugins/test1-backend-module-test2',
},
],
] as const)('resolvePackageInfo', (roleParams, packageInfo) => {
it(`should generate correct info with default config for ${roleParams.role}`, () => {
expect(
resolvePackageParams({
roleParams,
packagePrefix: '@internal/',
pluginInfix: 'plugin-',
}),
).toEqual(packageInfo);
});
});
@@ -0,0 +1,68 @@
/*
* 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.
*/
import { join as joinPath } from 'node:path';
import { PortableTemplateInputRoleParams } from '../types';
export type ResolvePackageParamsOptions = {
roleParams: PortableTemplateInputRoleParams;
pluginInfix: string;
packagePrefix: string;
};
export type PortableTemplatePackageInfo = {
packageName: string;
packagePath: string;
};
export function resolvePackageParams(
options: ResolvePackageParamsOptions,
): PortableTemplatePackageInfo {
const baseName = getBaseNameForRole(options.roleParams);
const isPlugin = options.roleParams.role.includes('plugin');
const pluginInfix = isPlugin ? options.pluginInfix : '';
return {
packageName: `${options.packagePrefix}${pluginInfix}${baseName}`,
packagePath: joinPath(isPlugin ? 'plugins' : 'packages', baseName),
};
}
function getBaseNameForRole(
roleParams: PortableTemplateInputRoleParams,
): string {
switch (roleParams.role) {
case 'web-library':
case 'node-library':
case 'common-library':
return roleParams.name;
case 'plugin-web-library':
return `${roleParams.pluginId}-react`;
case 'plugin-node-library':
return `${roleParams.pluginId}-node`;
case 'plugin-common-library':
return `${roleParams.pluginId}-common`;
case 'frontend-plugin':
return `${roleParams.pluginId}`;
case 'frontend-plugin-module':
return `${roleParams.pluginId}-module-${roleParams.moduleId}`;
case 'backend-plugin':
return `${roleParams.pluginId}-backend`;
case 'backend-plugin-module':
return `${roleParams.pluginId}-backend-module-${roleParams.moduleId}`;
default:
throw new Error(`Unknown role ${(roleParams as { role: string }).role}`);
}
}
@@ -0,0 +1,86 @@
/*
* 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.
*/
import { PortableTemplateConfig } from '../types';
import inquirer from 'inquirer';
import { withLogCollector } from '@backstage/test-utils';
import { selectTemplateInteractively } from './selectTemplateInteractively';
describe('selectTemplateInteractively', () => {
const mockConfig = {
isUsingDefaultTemplates: false,
templatePointers: [
{ name: 'template1', target: '/path/to/template1' },
{ name: 'template2', target: '/path/to/template2' },
],
} as PortableTemplateConfig;
beforeEach(() => {
jest.resetAllMocks();
});
it('should select a template interactively', async () => {
jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ name: 'template1' });
const result = await selectTemplateInteractively(mockConfig);
expect(result).toEqual({ name: 'template1', target: '/path/to/template1' });
});
it('should error if interactive selections is not found', async () => {
jest
.spyOn(inquirer, 'prompt')
.mockResolvedValueOnce({ name: 'nonexistent' });
await expect(selectTemplateInteractively(mockConfig)).rejects.toThrow(
"Template 'nonexistent' not found",
);
});
it('should use preselected template name', async () => {
const result = await selectTemplateInteractively(mockConfig, 'template2');
expect(result).toEqual({ name: 'template2', target: '/path/to/template2' });
});
it('should throw an error if template is not found', async () => {
await expect(
selectTemplateInteractively(mockConfig, 'nonexistent'),
).rejects.toThrow("Template 'nonexistent' not found");
});
it('should rewrite plugin to frontend-plugin if default templates are used', async () => {
await expect(
selectTemplateInteractively(mockConfig, 'plugin'),
).rejects.toThrow("Template 'plugin' not found");
const logs = await withLogCollector(async () => {
await expect(
selectTemplateInteractively(
{ ...mockConfig, isUsingDefaultTemplates: true },
'plugin',
),
).rejects.toThrow("Template 'frontend-plugin' not found");
});
expect(logs).toEqual({
log: [],
warn: [
"DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead",
],
error: [],
});
});
});
@@ -0,0 +1,54 @@
/*
* 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.
*/
import inquirer from 'inquirer';
import { PortableTemplateConfig, PortableTemplatePointer } from '../types';
export async function selectTemplateInteractively(
config: PortableTemplateConfig,
preselectedTemplateName?: string,
): Promise<PortableTemplatePointer> {
let selectedName = preselectedTemplateName;
if (config.isUsingDefaultTemplates && selectedName === 'plugin') {
console.warn(
`DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead`,
);
selectedName = 'frontend-plugin';
}
if (!selectedName) {
const answers = await inquirer.prompt<{ name: string }>([
{
type: 'list',
name: 'name',
message: 'What do you want to create?',
choices: config.templatePointers.map(t =>
t.description
? { name: `${t.name} - ${t.description}`, value: t.name }
: t.name,
),
},
]);
selectedName = answers.name;
}
const template = config.templatePointers.find(t => t.name === selectedName);
if (!template) {
throw new Error(`Template '${selectedName}' not found`);
}
return template;
}
+62
View File
@@ -0,0 +1,62 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import ora from 'ora';
const TASK_NAME_MAX_LENGTH = 14;
export class Task {
static log(name: string = '') {
process.stderr.write(`${chalk.green(name)}\n`);
}
static error(message: string = '') {
process.stderr.write(`\n${chalk.red(message)}\n\n`);
}
static section(name: string) {
const title = chalk.green(`${name}:`);
process.stderr.write(`\n ${title}\n`);
}
static exit(code: number = 0) {
process.exit(code);
}
static async forItem<T = void>(
task: string,
item: string,
taskFunc: () => Promise<T>,
): Promise<T> {
const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH));
const spinner = ora({
prefixText: chalk.green(` ${paddedTask}${chalk.cyan(item)}`),
spinner: 'arc',
color: 'green',
}).start();
try {
const result = await taskFunc();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}
}
+117
View File
@@ -0,0 +1,117 @@
/*
* Copyright 2021 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 type PortableTemplateConfig = {
/**
* The pointers to templates that can be used.
*/
templatePointers: PortableTemplatePointer[];
/**
* Whether the default set of templates are being used or not.
*/
isUsingDefaultTemplates: boolean;
license: string;
version: string;
private: boolean;
publishRegistry?: string;
packageNamePrefix: string;
packageNamePluginInfix: string;
};
export const TEMPLATE_FILE_NAME = 'portable-template.yaml';
export type PortableTemplatePointer = {
name: string;
description?: string;
target: string;
};
export const TEMPLATE_ROLES = [
'web-library',
'node-library',
'common-library',
'plugin-web-library',
'plugin-node-library',
'plugin-common-library',
'frontend-plugin',
'frontend-plugin-module',
'backend-plugin',
'backend-plugin-module',
] as const;
export type PortableTemplateRole = (typeof TEMPLATE_ROLES)[number];
export type PortableTemplateFile = {
path: string;
content: string;
syntax?: 'handlebars';
};
export type PortableTemplate = {
name: string;
role: PortableTemplateRole;
files: PortableTemplateFile[];
values: Record<string, string>;
};
export type PortableTemplateParams = {
[KName in string]?: string | number | boolean;
};
export type PortableTemplateInputRoleParams =
| {
role: 'web-library' | 'node-library' | 'common-library';
name: string;
}
| {
role:
| 'plugin-web-library'
| 'plugin-node-library'
| 'plugin-common-library'
| 'frontend-plugin'
| 'backend-plugin';
pluginId: string;
}
| {
role: 'frontend-plugin-module' | 'backend-plugin-module';
pluginId: string;
moduleId: string;
};
export type PortableTemplateInput = {
roleParams: PortableTemplateInputRoleParams;
owner?: string;
license: string;
version: string;
private: boolean;
publishRegistry?: string;
packageName: string;
packagePath: string;
};
@@ -0,0 +1,188 @@
/*
* Copyright 2020 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 { packageVersions, createPackageVersionProvider } from './version';
import { Lockfile } from '@backstage/cli-node';
import corePluginApiPkg from '@backstage/core-plugin-api/package.json';
import { createMockDirectory } from '@backstage/backend-test-utils';
describe('createPackageVersionProvider', () => {
const mockDir = createMockDirectory();
const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
it('should provide package versions', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"a@^0.1.0":
version "0.1.5"
"b@^0.2.0","b@*","b@^0.2.1":
version "0.2.5"
"c@^0.1.4":
version "0.1.8"
"c@^0.2.4":
version "0.2.8"
"c@^0.3.4":
version "0.3.8"
"@types/t@^1.1.0","@types/t@*","@types/t@^1.2.3":
version "1.4.5"
"@backstage/cli@*":
version "1.1.5"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile);
expect(provider('a', '0.1.5')).toBe('^0.1.0');
expect(provider('b', '1.0.0')).toBe('*');
expect(provider('c', '0.1.0')).toBe('^0.1.0');
expect(provider('c', '0.1.6')).toBe('^0.1.4');
expect(provider('c', '0.2.0')).toBe('^0.2.0');
expect(provider('c', '0.2.6')).toBe('^0.2.4');
expect(provider('c', '0.3.0-rc1')).toBe('0.3.0-rc1');
expect(provider('c', '0.3.0')).toBe('^0.3.0');
expect(provider('c', '0.3.6')).toBe('^0.3.4');
// No special handling for @types packages.
expect(provider('@types/t', '1.4.2')).toBe('^1.2.3');
const cliVersion = packageVersions['@backstage/cli'];
expect(provider('@backstage/cli')).toBe(
// If we're currently in pre-release we expect that to be picked instead
cliVersion.includes('-') ? `^${cliVersion}` : '*',
);
expect(provider('@backstage/core-plugin-api')).toBe(
`^${corePluginApiPkg.version}`,
);
});
describe('with backstage protocol options', () => {
it('should return backstage:^ for @backstage packages when preferBackstageProtocol is true', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('@backstage/core-plugin-api')).toBe('backstage:^');
expect(provider('@backstage/cli')).toBe('backstage:^');
});
it('should not return backstage:^ for non-@backstage packages even when preferBackstageProtocol is true', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"react@^18.0.0":
version "18.0.0"
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
"@internal/library@workspace:packages/internal":
version "0.0.0-use.local"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('react', '18.0.0')).toBe('^18.0.0');
expect(provider('@backstage/core-plugin-api')).toBe('backstage:^');
expect(provider('@internal/library')).toBe('workspace:^');
});
it('should prefer workspace ranges over backstage protocol', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"react@workspace:packages/internal":
version "0.0.0-use.local"
"@backstage/core-plugin-api@workspace:packages/internal":
version "0.0.0-use.local"
"@internal/library@workspace:packages/internal":
version "0.0.0-use.local"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('react')).toBe('workspace:^');
expect(provider('@backstage/core-plugin-api')).toBe('workspace:^');
expect(provider('@internal/library')).toBe('workspace:^');
});
// skipping this as it's broken in VP right now, and need a release.
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should not use backstage protocol when preferBackstageProtocol is false', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@*":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: false,
});
expect(provider('@backstage/core-plugin-api')).toBe('*');
});
// skipping this as it's broken in VP right now, and need a release.
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should not use backstage protocol when options are not provided', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@*":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile);
expect(provider('@backstage/core-plugin-api')).toBe('*');
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright 2020 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 semver from 'semver';
import { Lockfile } from '@backstage/cli-node';
/* eslint-disable @backstage/no-relative-monorepo-imports */
/*
This is a list of all packages used by the templates. If dependencies are added or removed,
this list should be updated as well.
The list, and the accompanying devDependencies entries, are here to ensure correct versioning
and bumping of this package. Without this list the version would not be bumped unless we
manually trigger a release.
This does not create an actual dependency on these packages and does not bring in any code.
Rollup will extract the value of the version field in each package at build time without
leaving any imports in place.
*/
type Pkg = { version: string };
const v = (path: string) => (require(path) as Pkg).version;
const backendPluginApi = v('../../../backend-plugin-api/package.json');
const backendTestUtils = v('../../../backend-test-utils/package.json');
const catalogClient = v('../../../catalog-client/package.json');
const cli = v('../../../cli/package.json');
const config = v('../../../config/package.json');
const coreAppApi = v('../../../core-app-api/package.json');
const coreComponents = v('../../../core-components/package.json');
const corePluginApi = v('../../../core-plugin-api/package.json');
const devUtils = v('../../../dev-utils/package.json');
const errors = v('../../../errors/package.json');
const frontendDefaults = v('../../../frontend-defaults/package.json');
const frontendPluginApi = v('../../../frontend-plugin-api/package.json');
const frontendTestUtils = v('../../../frontend-test-utils/package.json');
const testUtils = v('../../../test-utils/package.json');
const scaffolderNode = v('../../../../plugins/scaffolder-node/package.json');
const scaffolderNodeTestUtils = v('../../../../plugins/scaffolder-node-test-utils/package.json');
const authBackend = v('../../../../plugins/auth-backend/package.json');
const authBackendModuleGuestProvider = v('../../../../plugins/auth-backend-module-guest-provider/package.json');
const catalogNode = v('../../../../plugins/catalog-node/package.json');
const theme = v('../../../theme/package.json');
const types = v('../../../types/package.json');
const backendDefaults = v('../../../backend-defaults/package.json');
export const packageVersions: Record<string, string> = {
'@backstage/backend-defaults': backendDefaults,
'@backstage/backend-plugin-api': backendPluginApi,
'@backstage/backend-test-utils': backendTestUtils,
'@backstage/catalog-client': catalogClient,
'@backstage/cli': cli,
'@backstage/config': config,
'@backstage/core-app-api': coreAppApi,
'@backstage/core-components': coreComponents,
'@backstage/core-plugin-api': corePluginApi,
'@backstage/dev-utils': devUtils,
'@backstage/errors': errors,
'@backstage/frontend-defaults': frontendDefaults,
'@backstage/frontend-plugin-api': frontendPluginApi,
'@backstage/frontend-test-utils': frontendTestUtils,
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/types': types,
'@backstage/plugin-scaffolder-node': scaffolderNode,
'@backstage/plugin-scaffolder-node-test-utils': scaffolderNodeTestUtils,
'@backstage/plugin-auth-backend': authBackend,
'@backstage/plugin-auth-backend-module-guest-provider':
authBackendModuleGuestProvider,
'@backstage/plugin-catalog-node': catalogNode,
};
export function createPackageVersionProvider(
lockfile?: Lockfile,
options?: {
preferBackstageProtocol?: boolean;
},
) {
return (name: string, versionHint?: string): string => {
const packageVersion = packageVersions[name];
// 1) workspace precedence (existing logic) - check this first
const lockfileEntries = lockfile?.get(name);
const lockfileEntry = lockfileEntries?.find(entry =>
entry.range.startsWith('workspace:'),
);
if (lockfileEntry) {
return 'workspace:^';
}
// 2) backstage:^ when plugin is present and allowed
if (options?.preferBackstageProtocol && name.startsWith('@backstage/')) {
return 'backstage:^';
}
// 3) fallback to current npm resolution
const targetVersion = versionHint || packageVersion;
if (!targetVersion) {
throw new Error(`No version available for package ${name}`);
}
const validRanges = lockfileEntries?.filter(entry =>
semver.satisfies(targetVersion, entry.range),
);
const highestRange = validRanges?.slice(-1)[0];
if (highestRange?.range) {
return highestRange?.range;
}
if (packageVersion) {
return `^${packageVersion}`;
}
if (semver.parse(versionHint)?.prerelease.length) {
return versionHint!;
}
return versionHint?.match(/^[\d\.]+$/) ? `^${versionHint}` : versionHint!;
};
}