diff --git a/.changeset/tidy-beans-reflect.md b/.changeset/tidy-beans-reflect.md new file mode 100644 index 0000000000..20796075f0 --- /dev/null +++ b/.changeset/tidy-beans-reflect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 0b14a2e136..18fdef892a 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -41,6 +41,7 @@ lint Lint a package test Run tests, forwarding args to Jest, defaulting to watch mode clean Delete cache directories +create Open up an interactive guide to creating new things in your app create-plugin Creates a new plugin in the current repository remove-plugin Removes plugin in the current repository @@ -277,6 +278,44 @@ Options: -h, --help display help for command ``` +## create + +Scope: `root` + +The `create` command opens up an interactive guide for you to create new things +in your app. If you do not pass in any options it is completely interactive, but +it is possible to pre-select what you want to create using the `--select` flag, +and provide options using `--options`, for example: + +```bash +backstage-cli create --select plugin --option id=foo +``` + +This command is typically added as script in the root `package.json` to be +executed with `yarn backstage-create`, using options that are appropriate for +the organization that owns the app repo. For example you may have it set up like +this: + +```json +{ + "scripts": { + "backstage-create": "backstage-cli create --scope internal --no-private --npm-registry https://acme.org/npm" + } +} +``` + +```text +Usage: backstage-cli create [options] + +Options: + --select Select the thing you want to be creating upfront + --option = Pre-fill options for the creation process (default: []) + --scope The scope to use for new packages + --npm-registry The package registry to use for new packages + --no-private Do not mark new packages as private + -h, --help display help for command +``` + ## create-plugin Scope: `root` diff --git a/package.json b/package.json index ab0440fa67..75aecc36ac 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", - "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", + "backstage-create": "backstage-cli create --scope backstage --no-private", + "create-plugin": "yarn backstage-create --select plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install", "prettier:check": "prettier --check .", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index a5ea565cae..c80129662c 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -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(); diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts new file mode 100644 index 0000000000..64067e1b7e --- /dev/null +++ b/packages/cli/src/commands/create/create.ts @@ -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 { + const options: Record = {}; + + 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 =`, + ); + } + 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(); + async function createTemporaryDirectory(name: string): Promise { + 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}`, + ); + } + } + } +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4e01a8ca9f..a37d5e9ffe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -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 ', + 'Select the thing you want to be creating upfront', + ) + .option( + '--option =', + 'Pre-fill options for the creation process', + (opt, arr: string[]) => [...arr, opt], + [], + ) + .option('--scope ', 'The scope to use for new packages') + .option( + '--npm-registry ', + '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( diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 0390320128..8859b5d4cd 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -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); diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 563bd1052d..5734be95b8 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -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 { - 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 { - const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n'); + ownerStr: string, + codeownersFilePath?: string, +): Promise { + 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; } diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts new file mode 100644 index 0000000000..9072d46c4c --- /dev/null +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -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( + prompt: Prompt, + transforms: { + message: (msg: string) => string; + error: (msg: string) => string; + }, +): Prompt { + 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( + Object.values(factories).map(factory => [factory.name, factory]), + ); + + static async interactiveSelect(preselected?: string): Promise { + 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, + ): Promise> { + let currentOptions = provided; + + if (factory.optionsDiscovery) { + const discoveredOptions = await factory.optionsDiscovery(); + currentOptions = { + ...currentOptions, + ...(discoveredOptions as Record), + }; + } + + 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; + } +} diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts new file mode 100644 index 0000000000..5fe52c3732 --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -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, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts new file mode 100644 index 0000000000..4d03a4da7b --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -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({ + 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, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/common/prompts.ts b/packages/cli/src/lib/create/factories/common/prompts.ts new file mode 100644 index 0000000000..9c7672ddfb --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/prompts.ts @@ -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; + }, + }; +} diff --git a/packages/cli/src/lib/create/factories/common/tasks.test.ts b/packages/cli/src/lib/create/factories/common/tasks.test.ts new file mode 100644 index 0000000000..b3a751ba5b --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -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}}!'); + }); +}); diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts new file mode 100644 index 0000000000..fd3bd4d930 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -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; + }, +) { + 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(); +} diff --git a/packages/cli/src/lib/create/factories/common/testUtils.ts b/packages/cli/src/lib/create/factories/common/testUtils.ts new file mode 100644 index 0000000000..651409451e --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -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(); + 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; +} diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts new file mode 100644 index 0000000000..a882a130ea --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -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 = ( + + } /> + +) +`; + +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 = ( + + } /> + } /> + +) +`); + + 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 = ( + + } /> + } /> + +) +`); + + 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, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts new file mode 100644 index 0000000000..3862ae4883 --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -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({ + 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 = `} />`; + 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, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts new file mode 100644 index 0000000000..e4df9062f7 --- /dev/null +++ b/packages/cli/src/lib/create/factories/index.ts @@ -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'; diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts new file mode 100644 index 0000000000..a39bb3877d --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -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, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts new file mode 100644 index 0000000000..1bcca2fb6a --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -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({ + 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, + }); + }, +}); diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts new file mode 100644 index 0000000000..5a2460d0df --- /dev/null +++ b/packages/cli/src/lib/create/types.ts @@ -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; + + /** Signal that the creation process got to a point where permanent modifications were made */ + markAsModified(): void; +} + +export type AnyOptions = Record; + +export type Prompt = DistinctQuestion & { name: string }; + +export interface Factory { + /** + * 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>; + + /** + * Inquirer prompts that will be filled in either interactively or + * through command line arguments. + */ + optionsPrompts?: ReadonlyArray>; + + /** + * The main method of the factory that handles creation. + */ + create(options: TOptions, context?: CreateContext): Promise; +} + +export type AnyFactory = Factory; + +export function createFactory( + config: Factory, +): AnyFactory { + return config as AnyFactory; +} diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index e17015d6cf..c84eed435e 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -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( task: string, item: string, - taskFunc: () => Promise, - ): Promise { + taskFunc: () => Promise, + ): Promise { 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; + devDependencies?: Record; + peerDependencies?: Record; + }, +) { + try { + const pkgJson = await fs.readJson(path); + + const normalize = (obj: Record) => { + 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}`); + } +} diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 5cca7dcb07..37bcd94186 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -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" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/.eslintrc.js b/packages/cli/templates/default-common-plugin-package/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/cli/templates/default-common-plugin-package/README.md.hbs b/packages/cli/templates/default-common-plugin-package/README.md.hbs new file mode 100644 index 0000000000..917e18d4b9 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +Welcome to the common package for the {{id}} plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/default-common-plugin-package/package.json.hbs b/packages/cli/templates/default-common-plugin-package/package.json.hbs new file mode 100644 index 0000000000..efaee496e7 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -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" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs new file mode 100644 index 0000000000..2e1150d74e --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs @@ -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 diff --git a/packages/cli/templates/default-common-plugin-package/src/setupTests.ts b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli/templates/default-common-plugin-package/tsconfig.json b/packages/cli/templates/default-common-plugin-package/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 41376821ba..624302da93 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -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",