diff --git a/.changeset/cyan-roses-relate.md b/.changeset/cyan-roses-relate.md new file mode 100644 index 0000000000..e6dbeb6ed9 --- /dev/null +++ b/.changeset/cyan-roses-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added the ability to create a plain backend module with the `new` command. diff --git a/docs/backend-system/building-plugins-and-modules/01-index.md b/docs/backend-system/building-plugins-and-modules/01-index.md index 644816b053..750e18baf5 100644 --- a/docs/backend-system/building-plugins-and-modules/01-index.md +++ b/docs/backend-system/building-plugins-and-modules/01-index.md @@ -85,6 +85,8 @@ declare a dependency on the plugin package itself. This is to avoid a direct dependency and potentially cause duplicate installations of the plugin package, while duplicate installations of library packages should always be supported. +To create a Backend module, run `yarn new`, select `backend-module`, and fill out the rest of the prompts. This will create a new package at `plugins/-backend-module-`. + The following is an example of how to create a module that adds a new processor using the `catalogProcessingExtensionPoint`: diff --git a/packages/cli/package.json b/packages/cli/package.json index 95095fe78c..082a068fbd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -140,6 +140,8 @@ }, "devDependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", diff --git a/packages/cli/src/lib/new/factories/backendModule.test.ts b/packages/cli/src/lib/new/factories/backendModule.test.ts new file mode 100644 index 0000000000..c633b1c1b2 --- /dev/null +++ b/packages/cli/src/lib/new/factories/backendModule.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { backendModule } from './backendModule'; + +describe('backendModule 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(backendModule, { + id: 'test', + moduleId: 'tester-two', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await backendModule.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 module backstage-plugin-test-backend-module-tester-two', + 'Checking Prerequisites:', + `availability plugins${sep}test-backend-module-tester-two`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'templating index.ts.hbs', + 'templating module.ts.hbs', + 'Installing:', + `moving plugins${sep}test-backend-module-tester-two`, + 'backend adding dependency', + ]); + + await expect( + fs.readJson('/root/packages/backend/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test-backend-module-tester-two': '^1.0.0', + }, + }); + const moduleFile = await fs.readFile( + '/root/plugins/test-backend-module-tester-two/src/module.ts', + 'utf-8', + ); + + expect(moduleFile).toContain( + `const testModuleTesterTwo = createBackendModule({`, + ); + expect(moduleFile).toContain(`pluginId: 'test',`); + expect(moduleFile).toContain(`moduleId: 'tester-two',`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/new/factories/backendModule.ts b/packages/cli/src/lib/new/factories/backendModule.ts new file mode 100644 index 0000000000..221adcb1f3 --- /dev/null +++ b/packages/cli/src/lib/new/factories/backendModule.ts @@ -0,0 +1,100 @@ +/* + * 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 { + moduleIdIdPrompt, + ownerPrompt, + pluginIdPrompt, +} from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + moduleId: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const backendModule = createFactory({ + name: 'backend-module', + description: 'A new backend module', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), moduleIdIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id: pluginId, moduleId } = options; + const dirName = `${pluginId}-backend-module-${moduleId}`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${dirName}` + : `backstage-plugin-${dirName}`; + + Task.log(); + Task.log(`Creating backend module ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', dirName) + : paths.resolveTargetRoot(`backstage-plugin-${dirName}`); + + const moduleCamelCase = camelCase(moduleId); + const modulePascalCase = + moduleCamelCase[0].toUpperCase() + moduleCamelCase.slice(1); + const moduleVar = `${camelCase(pluginId)}Module${modulePascalCase}`; + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-backend-module', + values: { + pluginId, + moduleId, + name, + moduleVar, + packageVersion: 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/${dirName}`, 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/new/factories/common/prompts.ts b/packages/cli/src/lib/new/factories/common/prompts.ts index 9c7672ddfb..428ba8f9d3 100644 --- a/packages/cli/src/lib/new/factories/common/prompts.ts +++ b/packages/cli/src/lib/new/factories/common/prompts.ts @@ -33,6 +33,22 @@ export function pluginIdPrompt(): Prompt<{ id: string }> { }; } +export function moduleIdIdPrompt(): Prompt<{ moduleId: string }> { + return { + type: 'input', + name: 'moduleId', + message: 'Enter the ID of the module [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the ID of the module'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Module IDs must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }; +} + export function ownerPrompt(): Prompt<{ owner?: string; codeOwnersPath?: string; diff --git a/packages/cli/src/lib/new/factories/index.ts b/packages/cli/src/lib/new/factories/index.ts index 232e762b91..a4bcb2b721 100644 --- a/packages/cli/src/lib/new/factories/index.ts +++ b/packages/cli/src/lib/new/factories/index.ts @@ -16,6 +16,7 @@ export { frontendPlugin } from './frontendPlugin'; export { backendPlugin } from './backendPlugin'; +export { backendModule } from './backendModule'; export { webLibraryPackage } from './webLibraryPackage'; export { pluginCommon } from './pluginCommon'; export { pluginNode } from './pluginNode'; diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index bf89842d5f..96b42fcfb7 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -35,6 +35,8 @@ leaving any imports in place. */ import { version as backendCommon } from '../../../../packages/backend-common/package.json'; +import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json'; +import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json'; import { version as cli } from '../../../../packages/cli/package.json'; import { version as config } from '../../../../packages/config/package.json'; import { version as coreAppApi } from '../../../../packages/core-app-api/package.json'; @@ -47,6 +49,8 @@ import { version as scaffolderBackend } from '../../../../plugins/scaffolder-bac export const packageVersions: Record = { '@backstage/backend-common': backendCommon, + '@backstage/backend-plugin-api': backendPluginApi, + '@backstage/backend-test-utils': backendTestUtils, '@backstage/cli': cli, '@backstage/config': config, '@backstage/core-app-api': coreAppApi, diff --git a/packages/cli/templates/default-backend-module/.eslintrc.js b/packages/cli/templates/default-backend-module/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli/templates/default-backend-module/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli/templates/default-backend-module/README.md.hbs b/packages/cli/templates/default-backend-module/README.md.hbs new file mode 100644 index 0000000000..ad7669beda --- /dev/null +++ b/packages/cli/templates/default-backend-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +The {{moduleId}} backend module for the {{pluginId}} plugin. + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/default-backend-module/package.json.hbs b/packages/cli/templates/default-backend-module/package.json.hbs new file mode 100644 index 0000000000..07eee73d50 --- /dev/null +++ b/packages/cli/templates/default-backend-module/package.json.hbs @@ -0,0 +1,42 @@ +{ + "name": "{{name}}", + "description": "The {{moduleId}} backend module for the {{pluginId}} plugin.", + "version": "{{packageVersion}}", + "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", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}" + }, + "devDependencies": { + "@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}", + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-backend-module/src/index.ts.hbs b/packages/cli/templates/default-backend-module/src/index.ts.hbs new file mode 100644 index 0000000000..0cf1f00d42 --- /dev/null +++ b/packages/cli/templates/default-backend-module/src/index.ts.hbs @@ -0,0 +1,8 @@ +/***/ +/** + * The {{moduleId}} backend module for the {{pluginId}} plugin. + * + * @packageDocumentation + */ + +export { {{moduleVar}} } from './module'; diff --git a/packages/cli/templates/default-backend-module/src/module.ts.hbs b/packages/cli/templates/default-backend-module/src/module.ts.hbs new file mode 100644 index 0000000000..8c895a49c0 --- /dev/null +++ b/packages/cli/templates/default-backend-module/src/module.ts.hbs @@ -0,0 +1,14 @@ +import { coreServices, createBackendModule } from '@backstage/backend-plugin-api'; + +export const {{moduleVar}} = createBackendModule({ + pluginId: '{{pluginId}}', + moduleId: '{{moduleId}}', + register(reg) { + reg.registerInit({ + deps: { logger: coreServices.logger }, + async init({ logger }) { + logger.info('Hello World!') + }, + }); + }, +}); diff --git a/packages/cli/templates/default-backend-module/tsconfig.json b/packages/cli/templates/default-backend-module/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/default-backend-module/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/yarn.lock b/yarn.lock index ec0379375a..072a2c10c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3578,6 +3578,8 @@ __metadata: resolution: "@backstage/cli@workspace:packages/cli" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli-common": "workspace:^" "@backstage/cli-node": "workspace:^"