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:
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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!;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user