Merge pull request #8039 from backstage/rugvip/create

cli: add create command to replace create-plugin
This commit is contained in:
Patrik Oldsberg
2021-11-15 21:59:10 +01:00
committed by GitHub
30 changed files with 1708 additions and 65 deletions
@@ -263,7 +263,6 @@ export default async (cmd: Command) => {
const pluginDir = isMonoRepo
? paths.resolveTargetRoot('plugins', pluginId)
: paths.resolveTargetRoot(pluginId);
const ownerIds = parseOwnerIds(answers.owner);
const { version: pluginVersion } = isMonoRepo
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
: { version: '0.1.0' };
@@ -318,12 +317,8 @@ export default async (cmd: Command) => {
await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (ownerIds && ownerIds.length) {
await addCodeownersEntry(
codeownersPath!,
`/plugins/${pluginId}`,
ownerIds,
);
if (answers.owner) {
await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner);
}
Task.log();
+135
View File
@@ -0,0 +1,135 @@
/*
* 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 os from 'os';
import fs from 'fs-extra';
import { join as joinPath } from 'path';
import { Command } from 'commander';
import { FactoryRegistry } from '../../lib/create/FactoryRegistry';
import { paths } from '../../lib/paths';
import { assertError } from '@backstage/errors';
import { Task } from '../../lib/tasks';
function parseOptions(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;
}
export default async (cmd: Command) => {
const cmdOpts = cmd.opts();
const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select);
const providedOptions = parseOptions(cmdOpts.option);
const options = await FactoryRegistry.populateOptions(
factory,
providedOptions,
);
let isMonoRepo = false;
try {
const rootPackageJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
if (rootPackageJson.workspaces) {
isMonoRepo = true;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
let defaultVersion = '0.1.0';
try {
const rootLernaJson = await fs.readJson(
paths.resolveTargetRoot('lerna.json'),
);
if (rootLernaJson.version) {
defaultVersion = rootLernaJson.version;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
const tempDirs = new Array<string>();
async function createTemporaryDirectory(name: string): Promise<string> {
const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name));
tempDirs.push(dir);
return dir;
}
let modified = false;
try {
await factory.create(options, {
isMonoRepo,
defaultVersion,
scope: cmdOpts.scope?.replace(/^@/, ''),
npmRegistry: cmdOpts.npmRegistry,
private: Boolean(cmdOpts.private),
createTemporaryDirectory,
markAsModified() {
modified = true;
},
});
Task.log();
Task.log(`🎉 Successfully created ${factory.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 ${factory.name}!`);
}
} finally {
for (const dir of tempDirs) {
try {
await fs.remove(dir);
} catch (error) {
console.error(
`Failed to remove temporary directory '${dir}', ${error}`,
);
}
}
}
};
+24
View File
@@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) {
.option(...configOption)
.action(lazy(() => import('./backend/dev').then(m => m.default)));
program
.command('create')
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./create/create').then(m => m.default)));
program
.command('create-plugin')
.option(
@@ -177,9 +177,9 @@ describe('removePlugin', () => {
fse.readFileSync(mockedCodeownersPath, 'utf8'),
);
await addCodeownersEntry(
testFilePath!,
path.join('plugins', testPluginName),
['@thisIsAtestTeam', 'test@gmail.com'],
'@thisIsAtestTeam test@gmail.com',
testFilePath,
);
await removePluginFromCodeOwners(testFilePath, testPluginName);
expect(testFileContent).toBe(codeOwnersFileContent);
+24 -8
View File
@@ -16,6 +16,7 @@
import fs from 'fs-extra';
import path from 'path';
import { paths } from '../paths';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
@@ -30,14 +31,14 @@ type CodeownersEntry = {
export async function getCodeownersFilePath(
rootDir: string,
): Promise<string | undefined> {
const paths = [
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 paths) {
for (const p of possiblePaths) {
if (await fs.pathExists(p)) {
return p;
}
@@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean {
}
export function parseOwnerIds(
spaceSeparatedOwnerIds: string,
spaceSeparatedOwnerIds: string | undefined,
): string[] | undefined {
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
return undefined;
@@ -70,11 +71,24 @@ export function parseOwnerIds(
}
export async function addCodeownersEntry(
codeownersFilePath: string,
ownedPath: string,
ownerIds: string[],
): Promise<void> {
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
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(paths.targetRoot);
if (!filePath) {
return false;
}
}
const allLines = (await fs.readFile(filePath, 'utf8')).split('\n');
// Only keep comments from the top of the file
const commentLines = [];
@@ -117,5 +131,7 @@ export async function addCodeownersEntry(
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
await fs.writeFile(filePath, newLines.join('\n'), 'utf8');
return true;
}
@@ -0,0 +1,126 @@
/*
* 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 chalk from 'chalk';
import inquirer from 'inquirer';
import { AnyFactory, Prompt } from './types';
import * as factories from './factories';
import partition from 'lodash/partition';
function applyPromptMessageTransforms<T>(
prompt: Prompt<T>,
transforms: {
message: (msg: string) => string;
error: (msg: string) => string;
},
): Prompt<T> {
return {
...prompt,
message:
prompt.message &&
(async answers => {
if (typeof prompt.message === 'function') {
return transforms.message(await prompt.message(answers));
}
return transforms.message(await prompt.message!);
}),
validate:
prompt.validate &&
(async (...args) => {
const result = await prompt.validate!(...args);
if (typeof result === 'string') {
return transforms.error(result);
}
return result;
}),
};
}
export class FactoryRegistry {
private static factoryMap = new Map<string, AnyFactory>(
Object.values(factories).map(factory => [factory.name, factory]),
);
static async interactiveSelect(preselected?: string): Promise<AnyFactory> {
let selected = preselected;
if (!selected) {
const answers = await inquirer.prompt<{ name: string }>([
{
type: 'list',
name: 'name',
message: 'What do you want to create?',
choices: Array.from(this.factoryMap.values()).map(factory => ({
name: `${factory.name} - ${factory.description}`,
value: factory.name,
})),
},
]);
selected = answers.name;
}
const factory = this.factoryMap.get(selected);
if (!factory) {
throw new Error(`Unknown selection '${selected}'`);
}
return factory;
}
static async populateOptions(
factory: AnyFactory,
provided: Record<string, string>,
): Promise<Record<string, string>> {
let currentOptions = provided;
if (factory.optionsDiscovery) {
const discoveredOptions = await factory.optionsDiscovery();
currentOptions = {
...currentOptions,
...(discoveredOptions as Record<string, string>),
};
}
if (factory.optionsPrompts) {
const [hasAnswers, needsAnswers] = partition(
factory.optionsPrompts,
option => option.name in currentOptions,
);
for (const option of hasAnswers) {
const value = provided[option.name];
if (option.validate) {
const result = option.validate(value);
if (result !== true) {
throw new Error(`Invalid option '${option.name}'. ${result}`);
}
}
}
currentOptions = await inquirer.prompt(
needsAnswers.map(option =>
applyPromptMessageTransforms(option, {
message: chalk.blue,
error: chalk.red,
}),
),
currentOptions,
);
}
return currentOptions;
}
}
@@ -0,0 +1,114 @@
/*
* 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 mockFs from 'mock-fs';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { backendPlugin } from './backendPlugin';
describe('backendPlugin factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a backend plugin', async () => {
mockFs({
'/root': {
packages: {
backend: {
'package.json': JSON.stringify({}),
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(backendPlugin, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await backendPlugin.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test-backend',
'Checking Prerequisites:',
'availability plugins/test-backend ✔',
'creating temp dir ✔',
'Executing Template:',
'copying .eslintrc.js ✔',
'templating README.md.hbs ✔',
'templating package.json.hbs ✔',
'copying tsconfig.json ✔',
'copying index.ts ✔',
'templating run.ts.hbs ✔',
'copying setupTests.ts ✔',
'copying router.test.ts ✔',
'copying router.ts ✔',
'templating standaloneServer.ts.hbs ✔',
'Installing:',
'moving plugins/test-backend ✔',
'backend adding dependency ✔',
]);
await expect(
fs.readJson('/root/packages/backend/package.json'),
).resolves.toEqual({
dependencies: {
'backstage-plugin-test-backend': '^1.0.0',
},
});
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: '/root/plugins/test-backend',
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: '/root/plugins/test-backend',
optional: true,
});
});
});
@@ -0,0 +1,89 @@
/*
* 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 chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const backendPlugin = createFactory<Options>({
name: 'backend-plugin',
description: 'A new backend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const id = `${options.id}-backend`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-backend-plugin',
values: {
id,
name,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
await Task.forItem('backend', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/backend/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,58 @@
/*
* 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 { Prompt } from '../../types';
import { parseOwnerIds } from '../../../codeowners';
export function pluginIdPrompt(): Prompt<{ id: string }> {
return {
type: 'input',
name: 'id',
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 ownerPrompt(): Prompt<{
owner?: string;
codeOwnersPath?: string;
}> {
return {
type: 'input',
name: 'owner',
message: 'Enter an owner to add to CODEOWNERS [optional]',
when: opts => Boolean(opts.codeOwnersPath),
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,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.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { createMockOutputStream, mockPaths } from './testUtils';
import { CreateContext } from '../../types';
import { executePluginPackageTemplate } from './tasks';
mockPaths({
ownDir: '/own',
targetRoot: '/root',
});
describe('executePluginPackageTemplate', () => {
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should execute template', async () => {
mockFs({
'/root': {
'yarn.lock': `
some-package@^1.1.0:
version "1.5.0"
`,
},
'/own': {
templates: {
'test-template': {
'package.json.hbs': `
{
"name": "my-{{id}}-plugin",
{{#if makePrivate}}
"private": true,
{{/if}}
"description": "testing",
"dependencies": {
"some-package": "{{ versionQuery 'some-package' '1.3.0' }}",
"other-package": "{{ versionQuery 'other-package' '2.3.0' }}"
}
}
`,
subdir: {
'templated.txt.hbs': 'Hello {{id}}!',
'not-templated.txt': 'Hello {{id}}!',
},
},
},
},
});
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
let modified = false;
await executePluginPackageTemplate(
{
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
markAsModified: () => {
modified = true;
},
} as CreateContext,
{
templateName: 'test-template',
targetDir: '/target',
values: {
id: 'testing',
makePrivate: true,
},
},
);
expect(modified).toBe(true);
expect(output).toEqual([
'Checking Prerequisites:',
'availability /target ✔',
'creating temp dir ✔',
'Executing Template:',
'templating package.json.hbs ✔',
'copying not-templated.txt ✔',
'templating templated.txt.hbs ✔',
'Installing:',
'moving /target ✔',
]);
await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{
"name": "my-testing-plugin",
"private": true,
"description": "testing",
"dependencies": {
"some-package": "^1.1.0",
"other-package": "^2.3.0"
}
}
`);
await expect(
fs.readFile('/target/subdir/templated.txt', 'utf8'),
).resolves.toBe('Hello testing!');
await expect(
fs.readFile('/target/subdir/not-templated.txt', 'utf8'),
).resolves.toBe('Hello {{id}}!');
});
});
@@ -0,0 +1,84 @@
/*
* 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 chalk from 'chalk';
import { resolve as resolvePath } from 'path';
import { paths } from '../../../paths';
import { Task, templatingTask } from '../../../tasks';
import { Lockfile } from '../../../versioning';
import { createPackageVersionProvider } from '../../../version';
import { CreateContext } from '../../types';
export async function executePluginPackageTemplate(
ctx: CreateContext,
options: {
templateName: string;
targetDir: string;
values: Record<string, unknown>;
},
) {
const { targetDir } = options;
let lockfile: Lockfile | undefined;
try {
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
} catch {
/* ignored */
}
Task.section('Checking Prerequisites');
const shortPluginDir = targetDir.replace(`${paths.targetRoot}/`, '');
await Task.forItem('availability', shortPluginDir, async () => {
if (await fs.pathExists(targetDir)) {
throw new Error(
`A package with the same plugin ID already exists at ${chalk.cyan(
shortPluginDir,
)}. Please try again with a different ID.`,
);
}
});
const tempDir = await Task.forItem('creating', 'temp dir', async () => {
return await ctx.createTemporaryDirectory('backstage-create');
});
Task.section('Executing Template');
await templatingTask(
paths.resolveOwn('templates', options.templateName),
tempDir,
options.values,
createPackageVersionProvider(lockfile),
);
// Format package.json if it exists
const pkgJsonPath = resolvePath(tempDir, 'package.json');
if (await fs.pathExists(pkgJsonPath)) {
const pkgJson = await fs.readJson(pkgJsonPath);
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
Task.section('Installing');
await Task.forItem('moving', shortPluginDir, async () => {
await fs.move(tempDir, targetDir).catch(error => {
throw new Error(
`Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`,
);
});
});
ctx.markAsModified();
}
@@ -0,0 +1,68 @@
/*
* 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 { WriteStream } from 'tty';
import { resolve as resolvePath } from 'path';
import { paths } from '../../../paths';
export function mockPaths(options: {
ownDir?: string;
ownRoot?: string;
targetDir?: string;
targetRoot?: string;
}): void {
const { ownDir, ownRoot, targetDir, targetRoot } = options;
if (ownDir) {
paths.ownDir = ownDir;
jest
.spyOn(paths, 'resolveOwn')
.mockImplementation((...ps) => resolvePath(ownDir, ...ps));
}
if (ownRoot) {
jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot);
jest
.spyOn(paths, 'resolveOwnRoot')
.mockImplementation((...ps) => resolvePath(ownRoot, ...ps));
}
if (targetDir) {
paths.targetDir = targetDir;
jest
.spyOn(paths, 'resolveTarget')
.mockImplementation((...ps) => resolvePath(targetDir, ...ps));
}
if (targetRoot) {
jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...ps) => resolvePath(targetRoot, ...ps));
}
}
export function createMockOutputStream() {
const output = new Array<string>();
return [
output,
{
cursorTo: () => {},
clearLine: () => {},
moveCursor: () => {},
write: (msg: string) =>
// Clean up colors and whitespace
// eslint-disable-next-line no-control-regex
output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()),
} as unknown as WriteStream & { fd: any },
] as const;
}
@@ -0,0 +1,216 @@
/*
* 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 mockFs from 'mock-fs';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { frontendPlugin } from './frontendPlugin';
const appTsxContent = `
import { createApp } from '@backstage/app-defaults';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
</FlatRoutes>
)
`;
describe('frontendPlugin factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a frontend plugin', async () => {
mockFs({
'/root': {
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test',
'Checking Prerequisites:',
'availability plugins/test ✔',
'creating temp dir ✔',
'Executing Template:',
'copying .eslintrc.js ✔',
'templating README.md.hbs ✔',
'templating package.json.hbs ✔',
'copying tsconfig.json ✔',
'templating index.tsx.hbs ✔',
'templating index.ts.hbs ✔',
'templating plugin.test.ts.hbs ✔',
'templating plugin.ts.hbs ✔',
'templating routes.ts.hbs ✔',
'copying setupTests.ts ✔',
'templating ExampleComponent.test.tsx.hbs ✔',
'templating ExampleComponent.tsx.hbs ✔',
'copying index.ts ✔',
'templating ExampleFetchComponent.test.tsx.hbs ✔',
'templating ExampleFetchComponent.tsx.hbs ✔',
'copying index.ts ✔',
'Installing:',
'moving plugins/test ✔',
'app adding dependency ✔',
'app adding import ✔',
]);
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'backstage-plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from 'backstage-plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: '/root/plugins/test',
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: '/root/plugins/test',
optional: true,
});
});
it('should create a frontend plugin with more options and codeowners', async () => {
mockFs({
'/root': {
CODEOWNERS: '',
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
owner: '@test-user',
});
const [, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
scope: 'internal',
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'@internal/plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from '@internal/plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: '/root/plugins/test',
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: '/root/plugins/test',
optional: true,
});
});
});
@@ -0,0 +1,129 @@
/*
* 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 chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const frontendPlugin = createFactory<Options>({
name: 'plugin',
description: 'A new frontend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
const extensionName = `${upperFirst(camelCase(id))}Page`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-plugin',
values: {
id,
name,
extensionName,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) {
await Task.forItem('app', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/app/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
await Task.forItem('app', 'adding import', async () => {
const pluginsFilePath = paths.resolveTargetRoot(
'packages/app/src/App.tsx',
);
if (!(await fs.pathExists(pluginsFilePath))) {
return;
}
const content = await fs.readFile(pluginsFilePath, '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 importLine = `import { ${extensionName} } from '${name}';`;
if (!content.includes(importLine)) {
revLines.splice(lastImportIndex, 0, importLine);
}
const componentLine = `<Route path="/${id}" 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(pluginsFilePath, newContent, 'utf8');
}
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,19 @@
/*
* 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 { frontendPlugin } from './frontendPlugin';
export { backendPlugin } from './backendPlugin';
export { pluginCommon } from './pluginCommon';
@@ -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 fs from 'fs-extra';
import mockFs from 'mock-fs';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { pluginCommon } from './pluginCommon';
describe('pluginCommon factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a common plugin package', async () => {
mockFs({
'/root': {
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(pluginCommon, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await pluginCommon.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test-common',
'Checking Prerequisites:',
'availability plugins/test-common ✔',
'creating temp dir ✔',
'Executing Template:',
'copying .eslintrc.js ✔',
'templating README.md.hbs ✔',
'templating package.json.hbs ✔',
'copying tsconfig.json ✔',
'templating index.ts.hbs ✔',
'copying setupTests.ts ✔',
'Installing:',
'moving plugins/test-common ✔',
]);
await expect(
fs.readJson('/root/plugins/test-common/package.json'),
).resolves.toEqual(
expect.objectContaining({
name: 'backstage-plugin-test-common',
description: 'Common functionalities for the test plugin',
private: true,
version: '1.0.0',
}),
);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: '/root/plugins/test-common',
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: '/root/plugins/test-common',
optional: true,
});
});
});
@@ -0,0 +1,74 @@
/*
* 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 chalk from 'chalk';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const pluginCommon = createFactory<Options>({
name: 'plugin-common',
description: 'A new isomorphic common plugin package',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const suffix = `${id}-common`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${suffix}`
: `backstage-plugin-${suffix}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', suffix)
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-common-plugin-package',
values: {
id,
name,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
pluginVersion: ctx.defaultVersion,
},
});
if (options.owner) {
await addCodeownersEntry(`/plugins/${suffix}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
+77
View File
@@ -0,0 +1,77 @@
/*
* 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 { DistinctQuestion } from 'inquirer';
export interface CreateContext {
/** The package scope to use for new packages */
scope?: string;
/** The NPM registry to use for new packages */
npmRegistry?: string;
/** Whether new packages should be marked as private */
private: boolean;
/** Whether we are creating something in a monorepo or not */
isMonoRepo: boolean;
/** The default version to use for new packages */
defaultVersion: string;
/** Creates a temporary directory. This will always be deleted after creation is done. */
createTemporaryDirectory(name: string): Promise<string>;
/** Signal that the creation process got to a point where permanent modifications were made */
markAsModified(): void;
}
export type AnyOptions = Record<string, string>;
export type Prompt<TOptions> = DistinctQuestion<TOptions> & { name: string };
export interface Factory<TOptions extends AnyOptions> {
/**
* The name used for this factory.
*/
name: string;
/**
* A description that describes what this factory creates to the user.
*/
description: string;
/**
* An optional options discovery step that is run
* before the prompts to potentially fill in some of the options.
*/
optionsDiscovery?(): Promise<Partial<TOptions>>;
/**
* Inquirer prompts that will be filled in either interactively or
* through command line arguments.
*/
optionsPrompts?: ReadonlyArray<Prompt<TOptions>>;
/**
* The main method of the factory that handles creation.
*/
create(options: TOptions, context?: CreateContext): Promise<void>;
}
export type AnyFactory = Factory<AnyOptions>;
export function createFactory<TOptions extends AnyOptions>(
config: Factory<TOptions>,
): AnyFactory {
return config as AnyFactory;
}
+83 -8
View File
@@ -18,35 +18,40 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { promisify } from 'util';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
import { assertError } from '@backstage/errors';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
export class Task {
static log(name: string = '') {
process.stdout.write(`${chalk.green(name)}\n`);
process.stderr.write(`${chalk.green(name)}\n`);
}
static error(message: string = '') {
process.stdout.write(`\n${chalk.red(message)}\n\n`);
process.stderr.write(`\n${chalk.red(message)}\n\n`);
}
static section(name: string) {
const title = chalk.green(`${name}:`);
process.stdout.write(`\n ${title}\n`);
process.stderr.write(`\n ${title}\n`);
}
static exit(code: number = 0) {
process.exit(code);
}
static async forItem(
static async forItem<T = void>(
task: string,
item: string,
taskFunc: () => Promise<void>,
): Promise<void> {
taskFunc: () => Promise<T>,
): Promise<T> {
const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH));
const spinner = ora({
@@ -56,13 +61,40 @@ export class Task {
}).start();
try {
await taskFunc();
const result = await taskFunc();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}
static async forCommand(
command: string,
options?: { cwd?: string; optional?: boolean },
) {
try {
await Task.forItem('executing', command, async () => {
await exec(command, { cwd: options?.cwd });
});
} catch (error) {
assertError(error);
if (error.stderr) {
process.stderr.write(error.stderr as Buffer);
}
if (error.stdout) {
process.stdout.write(error.stdout as Buffer);
}
if (options?.optional) {
Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`);
} else {
throw new Error(
`Failed to execute command '${chalk.cyan(command)}', ${error}`,
);
}
}
}
}
export async function templatingTask(
@@ -85,7 +117,9 @@ export async function templatingTask(
const destination = destinationFile.replace(/\.hbs$/, '');
const template = await fs.readFile(file);
const compiled = handlebars.compile(template.toString());
const compiled = handlebars.compile(template.toString(), {
strict: true,
});
const contents = compiled(
{ name: basename(destination), ...context },
{
@@ -122,3 +156,44 @@ export async function templatingTask(
}
}
}
export async function addPackageDependency(
path: string,
options: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
},
) {
try {
const pkgJson = await fs.readJson(path);
const normalize = (obj: Record<string, string>) => {
if (Object.keys(obj).length === 0) {
return undefined;
}
return Object.fromEntries(
Object.keys(obj)
.sort()
.map(key => [key, obj[key]]),
);
};
pkgJson.dependencies = normalize({
...pkgJson.dependencies,
...options.dependencies,
});
pkgJson.devDependencies = normalize({
...pkgJson.devDependencies,
...options.devDependencies,
});
pkgJson.peerDependencies = normalize({
...pkgJson.peerDependencies,
...options.peerDependencies,
});
await fs.writeJson(path, pkgJson, { spaces: 2 });
} catch (error) {
throw new Error(`Failed to add package dependencies, ${error}`);
}
}
@@ -4,41 +4,43 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{/if}}
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,5 @@
# {{name}}
Welcome to the common package for the {{id}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,34 @@
{
"name": "{{name}}",
"description": "Common functionalities for the {{id}} plugin",
"version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,19 @@
/***/
/**
* Common functionalities for the {{id}} plugin.
*
* @packageDocumentation
*/
/**
* In this package you might for example declare types that are common
* between the frontend and backend plugin packages.
*/
export type CommonType = {
field: string
}
/**
* Or you might declare some common constants.
*/
export const COMMON_CONSTANT = 1
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": "."
}
}
@@ -4,10 +4,12 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.esm.js",