Move yarn new templates from @backstage/cli to @backstage/cli-module-new

The built-in templates used by `yarn new` are moved from `packages/cli/templates/`
to `packages/cli-module-new/templates/`, colocating them with the code that
consumes them.

A backwards compatibility rewrite in the template resolution ensures that
existing `@backstage/cli/templates/*` references in root `package.json`
configurations continue to work by transparently resolving them to
`@backstage/cli-module-new/templates/*`.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 12:39:05 +01:00
parent e9eb585978
commit ea90ab0590
129 changed files with 146 additions and 49 deletions
+4 -3
View File
@@ -19,9 +19,11 @@
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"bin": "bin/backstage-cli-module-new",
"files": [
"dist",
"bin"
"bin",
"templates"
],
"scripts": {
"build": "backstage-cli package build",
@@ -57,6 +59,5 @@
"@types/inquirer": "^8.1.3",
"@types/lodash": "^4.14.151",
"@types/recursive-readdir": "^2.2.0"
},
"bin": "bin/backstage-cli-module-new"
}
}
@@ -15,15 +15,15 @@
*/
export const defaultTemplates = [
'@backstage/cli/templates/frontend-plugin',
'@backstage/cli/templates/backend-plugin',
'@backstage/cli/templates/backend-plugin-module',
'@backstage/cli/templates/plugin-web-library',
'@backstage/cli/templates/plugin-node-library',
'@backstage/cli/templates/plugin-common-library',
'@backstage/cli/templates/web-library',
'@backstage/cli/templates/node-library',
'@backstage/cli/templates/cli-module',
'@backstage/cli/templates/catalog-provider-module',
'@backstage/cli/templates/scaffolder-backend-module',
'@backstage/cli-module-new/templates/frontend-plugin',
'@backstage/cli-module-new/templates/backend-plugin',
'@backstage/cli-module-new/templates/backend-plugin-module',
'@backstage/cli-module-new/templates/plugin-web-library',
'@backstage/cli-module-new/templates/plugin-node-library',
'@backstage/cli-module-new/templates/plugin-common-library',
'@backstage/cli-module-new/templates/web-library',
'@backstage/cli-module-new/templates/node-library',
'@backstage/cli-module-new/templates/cli-module',
'@backstage/cli-module-new/templates/catalog-provider-module',
'@backstage/cli-module-new/templates/scaffolder-backend-module',
];
@@ -200,6 +200,72 @@ describe('loadPortableTemplateConfig', () => {
});
});
it('should rewrite legacy @backstage/cli/templates paths', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
backstage: {
cli: {
new: {
templates: [
'@backstage/cli/templates/backend-plugin',
'@backstage/cli/templates/frontend-plugin',
],
},
},
},
}),
node_modules: {
'@backstage': {
'cli-module-new': {
templates: {
'backend-plugin': {
[TEMPLATE_FILE_NAME]:
'name: backend-plugin\nrole: backend-plugin\n',
},
'frontend-plugin': {
[TEMPLATE_FILE_NAME]:
'name: frontend-plugin\nrole: frontend-plugin\n',
},
},
},
},
},
});
await expect(
loadPortableTemplateConfig({
packagePath: mockDir.resolve('package.json'),
}),
).resolves.toEqual({
isUsingDefaultTemplates: false,
templatePointers: [
{
name: 'backend-plugin',
target: realpathSync(
mockDir.resolve(
'node_modules/@backstage/cli-module-new/templates/backend-plugin',
TEMPLATE_FILE_NAME,
),
),
},
{
name: 'frontend-plugin',
target: realpathSync(
mockDir.resolve(
'node_modules/@backstage/cli-module-new/templates/frontend-plugin',
TEMPLATE_FILE_NAME,
),
),
},
],
license: 'Apache-2.0',
private: true,
version: '0.1.0',
packageNamePrefix: '@internal/',
packageNamePluginInfix: 'backstage-plugin-',
});
});
it('should reject templates with conflicting names', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
@@ -157,6 +157,9 @@ export async function loadPortableTemplateConfig(
};
}
const CLI_TEMPLATE_PREFIX = '@backstage/cli/templates/';
const CLI_MODULE_NEW_TEMPLATE_PREFIX = '@backstage/cli-module-new/templates/';
function resolveLocalTemplatePath(pointer: string, basePath: string): string {
if (isAbsolute(pointer)) {
throw new Error(`Template target may not be an absolute path`);
@@ -166,7 +169,14 @@ function resolveLocalTemplatePath(pointer: string, basePath: string): string {
return resolvePath(basePath, pointer, TEMPLATE_FILE_NAME);
}
return require.resolve(`${pointer}/${TEMPLATE_FILE_NAME}`, {
// Rewrite legacy @backstage/cli/templates/* paths to @backstage/cli-module-new/templates/*
const resolvedPointer = pointer.startsWith(CLI_TEMPLATE_PREFIX)
? `${CLI_MODULE_NEW_TEMPLATE_PREFIX}${pointer.slice(
CLI_TEMPLATE_PREFIX.length,
)}`
: pointer;
return require.resolve(`${resolvedPointer}/${TEMPLATE_FILE_NAME}`, {
paths: [basePath],
});
}
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
The {{moduleId}} backend module for the {{pluginId}} plugin.
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,34 @@
{
"name": "{{packageName}}",
"description": "The {{moduleId}} backend module for the {{pluginId}} plugin.",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module",
"pluginId": "{{pluginId}}"
},
"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-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}"
},
"devDependencies": {
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,5 @@
name: backend-plugin-module
role: backend-plugin-module
description: A new backend module that extends an existing backend plugin
values:
moduleVar: '{{ camelCase pluginId }}Module{{ upperFirst ( camelCase moduleId ) }}'
@@ -0,0 +1,8 @@
/***/
/**
* The {{moduleId}} backend module for the {{pluginId}} plugin.
*
* @packageDocumentation
*/
export { {{moduleVar}} as default } from './module';
@@ -0,0 +1,17 @@
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!');
},
});
},
});
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,28 @@
# {{pluginId}}
This plugin backend was templated using the Backstage CLI. You should replace this text with a description of your plugin backend.
## Installation
This plugin is installed via the `{{packageName}}` package. To install it to your backend package, run the following command:
```bash
# From your root directory
yarn --cwd packages/backend add {{packageName}}
```
Then add the plugin to your backend in `packages/backend/src/index.ts`:
```ts
const backend = createBackend();
// ...
backend.add(import('{{packageName}}'));
```
## Development
This plugin backend can be started in a standalone mode from directly in this
package with `yarn start`. It is a limited setup that is most convenient when
developing the plugin backend itself.
If you want to run the entire project, including the frontend, run `yarn start` from the root directory.
@@ -0,0 +1,60 @@
import { createBackend } from '@backstage/backend-defaults';
import { mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
// TEMPLATE NOTE:
// This is the development setup for your plugin that wires up a
// minimal backend that can use both real and mocked plugins and services.
//
// Start up the backend by running `yarn start` in the package directory.
// Once it's up and running, try out the following requests:
//
// Create a new todo item, standalone or for the sample component:
//
// curl http://localhost:7007/api/{{pluginId}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo"}'
// curl http://localhost:7007/api/{{pluginId}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo", "entityRef": "component:default/sample"}'
//
// List TODOs:
//
// curl http://localhost:7007/api/{{pluginId}}/todos
//
// Explicitly make an unauthenticated request, or with service auth:
//
// curl http://localhost:7007/api/{{pluginId}}/todos -H 'Authorization: Bearer mock-none-token'
// curl http://localhost:7007/api/{{pluginId}}/todos -H 'Authorization: Bearer mock-service-token'
const backend = createBackend();
// TEMPLATE NOTE:
// Mocking the auth and httpAuth service allows you to call your plugin API without
// having to authenticate.
//
// If you want to use real auth, you can install the following instead:
// backend.add(import('@backstage/plugin-auth-backend'));
// backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(mockServices.auth.factory());
backend.add(mockServices.httpAuth.factory());
// TEMPLATE NOTE:
// Rather than using a real catalog you can use a mock with a fixed set of entities.
backend.add(
catalogServiceMock.factory({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'sample',
title: 'Sample Component',
},
spec: {
type: 'service',
},
},
],
}),
);
backend.add(import('../src'));
backend.start();
@@ -0,0 +1,44 @@
{
"name": "{{packageName}}",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin",
"pluginId": "{{pluginId}}"
},
"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-defaults": "{{versionQuery '@backstage/backend-defaults'}}",
"@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}",
"@backstage/catalog-client": "{{versionQuery '@backstage/catalog-client'}}",
"@backstage/errors": "{{versionQuery '@backstage/errors'}}",
"@backstage/types": "{{versionQuery '@backstage/types'}}",
"@backstage/plugin-catalog-node": "{{versionQuery '@backstage/plugin-catalog-node'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"zod": "{{versionQuery 'zod' '3.25.76'}}"
},
"devDependencies": {
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.12'}}",
"supertest": "{{versionQuery 'supertest' '6.2.4'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,5 @@
name: backend-plugin
role: backend-plugin
description: A new backend plugin
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
@@ -0,0 +1 @@
export { {{pluginVar}} as default } from './plugin';
@@ -0,0 +1,129 @@
import {
mockCredentials,
startTestBackend,
} from '@backstage/backend-test-utils';
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { todoListServiceRef } from './services/TodoListService';
import { {{pluginVar}} } from './plugin';
import request from 'supertest';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import {
ConflictError,
AuthenticationError,
NotAllowedError,
} from '@backstage/errors';
// TEMPLATE NOTE:
// Plugin tests are integration tests for your plugin, ensuring that all pieces
// work together end-to-end. You can still mock injected backend services
// however, just like anyone who installs your plugin might replace the
// services with their own implementations.
describe('plugin', () => {
it('should create and read TODO items', async () => {
const { server } = await startTestBackend({
features: [{{pluginVar}}],
});
await request(server).get('/api/{{pluginId}}/todos').expect(200, {
items: [],
});
const createRes = await request(server)
.post('/api/{{pluginId}}/todos')
.send({ title: 'My Todo' });
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual({
id: expect.any(String),
title: 'My Todo',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: expect.any(String),
});
const createdTodoItem = createRes.body;
await request(server)
.get('/api/{{pluginId}}/todos')
.expect(200, {
items: [createdTodoItem],
});
await request(server)
.get(`/api/{{pluginId}}/todos/${createdTodoItem.id}`)
.expect(200, createdTodoItem);
});
it('should create TODO item with catalog information', async () => {
const { server } = await startTestBackend({
features: [
{{pluginVar}},
catalogServiceMock.factory({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
namespace: 'default',
title: 'My Component',
},
spec: {
type: 'service',
owner: 'me',
},
},
],
}),
],
});
const createRes = await request(server)
.post('/api/{{pluginId}}/todos')
.send({ title: 'My Todo', entityRef: 'component:default/my-component' });
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual({
id: expect.any(String),
title: '[My Component] My Todo',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: expect.any(String),
});
});
it('should forward errors from the TodoListService', async () => {
const { server } = await startTestBackend({
features: [
{{pluginVar}},
createServiceFactory({
service: todoListServiceRef,
deps: {},
factory: () => ({
createTodo: jest.fn().mockRejectedValue(new ConflictError()),
listTodos: jest.fn().mockRejectedValue(new AuthenticationError()),
getTodo: jest.fn().mockRejectedValue(new NotAllowedError()),
}),
})
],
});
const createRes = await request(server)
.post('/api/{{pluginId}}/todos')
.send({ title: 'My Todo', entityRef: 'component:default/my-component' });
expect(createRes.status).toBe(409);
expect(createRes.body).toMatchObject({
error: { name: 'ConflictError' },
});
const listRes = await request(server).get('/api/{{pluginId}}/todos');
expect(listRes.status).toBe(401);
expect(listRes.body).toMatchObject({
error: { name: 'AuthenticationError' },
});
const getRes = await request(server).get('/api/{{pluginId}}/todos/123');
expect(getRes.status).toBe(403);
expect(getRes.body).toMatchObject({
error: { name: 'NotAllowedError' },
});
});
});
@@ -0,0 +1,32 @@
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import { todoListServiceRef } from './services/TodoListService';
/**
* {{pluginVar}} backend plugin
*
* @public
*/
export const {{pluginVar}} = createBackendPlugin({
pluginId: '{{pluginId}}',
register(env) {
env.registerInit({
deps: {
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
todoList: todoListServiceRef,
},
async init({ httpAuth, httpRouter, todoList }) {
httpRouter.use(
await createRouter({
httpAuth,
todoList,
}),
);
},
});
},
});
@@ -0,0 +1,67 @@
import {
mockCredentials,
mockErrorHandler,
mockServices,
} from '@backstage/backend-test-utils';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { todoListServiceRef } from './services/TodoListService';
const mockTodoItem = {
title: 'Do the thing',
id: '123',
createdBy: mockCredentials.user().principal.userEntityRef,
createdAt: new Date().toISOString(),
};
// TEMPLATE NOTE:
// Testing the router directly allows you to write a unit test that mocks the provided options.
describe('createRouter', () => {
let app: express.Express;
let todoList: jest.Mocked<typeof todoListServiceRef.T>;
beforeEach(async () => {
todoList = {
createTodo: jest.fn(),
listTodos: jest.fn(),
getTodo: jest.fn(),
};
const router = await createRouter({
httpAuth: mockServices.httpAuth(),
todoList,
});
app = express();
app.use(router);
app.use(mockErrorHandler());
});
it('should create a TODO', async () => {
todoList.createTodo.mockResolvedValue(mockTodoItem);
const response = await request(app).post('/todos').send({
title: 'Do the thing',
});
expect(response.status).toBe(201);
expect(response.body).toEqual(mockTodoItem);
});
it('should not allow unauthenticated requests to create a TODO', async () => {
todoList.createTodo.mockResolvedValue(mockTodoItem);
// TEMPLATE NOTE:
// The HttpAuth mock service considers all requests to be authenticated as a
// mock user by default. In order to test other cases we need to explicitly
// pass an authorization header with mock credentials.
const response = await request(app)
.post('/todos')
.set('Authorization', mockCredentials.none.header())
.send({
title: 'Do the thing',
});
expect(response.status).toBe(401);
});
});
@@ -0,0 +1,51 @@
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { InputError } from '@backstage/errors';
import { z } from 'zod';
import express from 'express';
import Router from 'express-promise-router';
import { todoListServiceRef } from './services/TodoListService';
export async function createRouter({
httpAuth,
todoList,
}: {
httpAuth: HttpAuthService;
todoList: typeof todoListServiceRef.T;
}): Promise<express.Router> {
const router = Router();
router.use(express.json());
// TEMPLATE NOTE:
// Zod is a powerful library for data validation and recommended in particular
// for user-defined schemas. In this case we use it for input validation too.
//
// If you want to define a schema for your API we recommend using Backstage's
// OpenAPI tooling: https://backstage.io/docs/next/openapi/01-getting-started
const todoSchema = z.object({
title: z.string(),
entityRef: z.string().optional(),
});
router.post('/todos', async (req, res) => {
const parsed = todoSchema.safeParse(req.body);
if (!parsed.success) {
throw new InputError(parsed.error.toString());
}
const result = await todoList.createTodo(parsed.data, {
credentials: await httpAuth.credentials(req, { allow: ['user'] }),
});
res.status(201).json(result);
});
router.get('/todos', async (_req, res) => {
res.json(await todoList.listTodos());
});
router.get('/todos/:id', async (req, res) => {
res.json(await todoList.getTodo({ id: req.params.id }));
});
return router;
}
@@ -0,0 +1,155 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import crypto from 'node:crypto';
import {
coreServices,
createServiceFactory,
createServiceRef,
LoggerService,
} from '@backstage/backend-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import {
BackstageCredentials,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
import { Expand } from '@backstage/types';
export interface TodoItem {
title: string;
id: string;
createdBy: string;
createdAt: string;
}
// TEMPLATE NOTE:
// This is a simple in-memory todo list store. It is recommended to use a
// database to store data in a real application. See the database service
// documentation for more information on how to do this:
// https://backstage.io/docs/backend-system/core-services/database
export class TodoListService {
readonly #logger: LoggerService;
readonly #catalog: typeof catalogServiceRef.T;
readonly #storedTodos = new Array<TodoItem>();
static create(options: {
logger: LoggerService;
catalog: typeof catalogServiceRef.T;
}) {
return new TodoListService(options.logger, options.catalog);
}
private constructor(
logger: LoggerService,
catalog: typeof catalogServiceRef.T,
) {
this.#logger = logger;
this.#catalog = catalog;
}
async createTodo(
input: {
title: string;
entityRef?: string;
},
options: {
credentials: BackstageCredentials<BackstageUserPrincipal>;
},
): Promise<TodoItem> {
let title = input.title;
// TEMPLATE NOTE:
// A common pattern for Backstage plugins is to pass an entity reference
// from the frontend to then fetch the entire entity from the catalog in the
// backend plugin.
if (input.entityRef) {
// TEMPLATE NOTE:
// Cross-plugin communication uses service-to-service authentication. The
// `AuthService` lets you generate a token that is valid for communication
// with the target plugin only. You must also provide credentials for the
// identity that you are making the request on behalf of.
//
// If you want to make a request using the plugin backend's own identity,
// you can access it via the `auth.getOwnServiceCredentials()` method.
// Beware that this bypasses any user permission checks.
const entity = await this.#catalog.getEntityByRef(
input.entityRef,
options,
);
if (!entity) {
throw new NotFoundError(`No entity found for ref '${input.entityRef}'`);
}
// TEMPLATE NOTE:
// Here you could read any form of data from the entity. A common use case
// is to read the value of a custom annotation for your plugin. You can
// read more about how to add custom annotations here:
// https://backstage.io/docs/features/software-catalog/extending-the-model#adding-a-new-annotation
//
// In this example we just use the entity title to decorate the todo item.
const entityDisplay = entity.metadata.title ?? input.entityRef;
title = `[${entityDisplay}] ${input.title}`;
}
const id = crypto.randomUUID();
const createdBy = options.credentials.principal.userEntityRef;
const newTodo = {
title,
id,
createdBy,
createdAt: new Date().toISOString(),
};
this.#storedTodos.push(newTodo);
// TEMPLATE NOTE:
// The second argument of the logger methods can be used to pass
// structured metadata. You can read more about the logger service here:
// https://backstage.io/docs/backend-system/core-services/logger
this.#logger.info('Created new todo item', { id, title, createdBy });
return newTodo;
}
async listTodos(): Promise<{ items: TodoItem[] }> {
return { items: Array.from(this.#storedTodos) };
}
async getTodo(request: { id: string }): Promise<TodoItem> {
const todo = this.#storedTodos.find(item => item.id === request.id);
if (!todo) {
throw new NotFoundError(`No todo found with id '${request.id}'`);
}
return todo;
}
}
export const todoListServiceRef = createServiceRef<Expand<TodoListService>>({
id: 'todo.list',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {
logger: coreServices.logger,
catalog: catalogServiceRef,
},
async factory(deps) {
return TodoListService.create(deps);
},
}),
});
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
The {{fullModuleId}} module for [@backstage/plugin-catalog-backend](https://www.npmjs.com/package/@backstage/plugin-catalog-backend).
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,34 @@
import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api';
export interface Config {
catalog?: {
providers?: {
/**
* {{providerClass}} configuration.
*/
{{providerVar}}?:
| {
/**
* The target that this provider should consume.
*/
target: string;
/**
* Overrides the schedule at which this provider runs.
*/
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
}
| {
[name: string]: {
/**
* The target that this provider should consume.
*/
target: string;
/**
* Overrides the schedule at which this provider runs.
*/
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
};
};
};
};
}
@@ -0,0 +1,36 @@
{
"name": "{{packageName}}",
"description": "The {{fullModuleId}} module for @backstage/plugin-catalog-backend",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module",
"pluginId": "catalog",
"pluginPackage": "@backstage/plugin-catalog-backend"
},
"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-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}",
"@backstage/plugin-catalog-node": "{{versionQuery '@backstage/plugin-catalog-node'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,9 @@
name: catalog-provider-module
role: backend-plugin-module
description: An Entity Provider module for the Software Catalog
values:
pluginId: catalog
fullModuleId: '{{ moduleId }}-provider'
moduleVar: '{{ camelCase pluginId }}Module{{ upperFirst ( camelCase moduleId ) }}'
providerVar: '{{ camelCase moduleId }}Provider'
providerClass: '{{ upperFirst ( camelCase moduleId ) }}Provider'
@@ -0,0 +1,8 @@
/***/
/**
* The {{fullModuleId}} module for @backstage/plugin-catalog-backend
*
* @packageDocumentation
*/
export { {{moduleVar}} as default } from './module';
@@ -0,0 +1,29 @@
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node';
import { {{providerClass}} } from './provider/{{providerClass}}';
export const {{moduleVar}} = createBackendModule({
moduleId: '{{fullModuleId}}',
pluginId: '{{pluginId}}',
register({ registerInit }) {
registerInit({
deps: {
logger: coreServices.logger,
config: coreServices.rootConfig,
scheduler: coreServices.scheduler,
processing: catalogProcessingExtensionPoint,
},
async init({ logger, scheduler, config, processing }) {
processing.addEntityProvider(
{{providerClass}}.fromConfig(config, {
logger,
scheduler,
}),
);
}
});
},
})
@@ -0,0 +1,78 @@
import {
readSchedulerServiceTaskScheduleDefinitionFromConfig,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
const DEFAULT_PROVIDER_ID = 'default';
const DEFAULT_SCHEDULE: SchedulerServiceTaskScheduleDefinition = {
frequency: {
minutes: 30,
},
timeout: {
minutes: 3,
},
}
export type {{providerClass}}ProviderConfig = {
id: string;
target: string;
schedule: SchedulerServiceTaskScheduleDefinition;
}
/**
* Parses all configured providers.
*
* @param config - The root of the provider config hierarchy
*
* @public
*/
export function readProviderConfigs(
config: Config,
): {{providerClass}}ProviderConfig[] {
const providersConfig = config.getOptionalConfig(
'catalog.providers.{{providerVar}}',
);
if (!providersConfig) {
return [];
}
if ((providersConfig).has('target')) {
// simple/single config variant
return [readProviderConfig(DEFAULT_PROVIDER_ID, providersConfig)];
}
return providersConfig.keys().map(id => {
const providerConfig = providersConfig.getConfig(id);
return readProviderConfig(id, providerConfig);
});
}
/**
* Parses a single configured provider by id.
*
* @param id - the id of the provider to parse
* @param config - The root of the provider config hierarchy
*
* @public
*/
export function readProviderConfig(
id: string,
config: Config,
): {{providerClass}}ProviderConfig {
const target = config.getString('target');
const schedule = config.has('schedule')
? readSchedulerServiceTaskScheduleDefinitionFromConfig(
config.getConfig('schedule'),
)
: DEFAULT_SCHEDULE;
return {
id,
target,
schedule,
};
}
@@ -0,0 +1,18 @@
import { {{providerClass}} } from './{{providerClass}}';
import { mockServices } from '@backstage/backend-test-utils';
describe('{{providerClass}}', () => {
it('should read entities from the target', async () => {
const logger = mockServices.logger.mock();
const provider = new {{providerClass}}({
id: 'test',
target: 'https://example.com',
logger: mockServices.logger.mock(),
taskRunner: { run: jest.fn() },
});
const entities = await provider.read({ logger });
expect(entities).toEqual([]);
});
})
@@ -0,0 +1,109 @@
import { Config } from '@backstage/config';
import {
DeferredEntity,
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import * as uuid from 'uuid';
import { readProviderConfigs } from './readProviderConfigs';
import {
LoggerService,
SchedulerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
export type {{providerClass}}Options = {
/**
* The logger to use.
*/
logger: LoggerService;
/**
* Scheduler used to schedule refreshes based on
* the schedule config.
*/
scheduler: SchedulerService;
};
export class {{providerClass}} implements EntityProvider {
static fromConfig(
configRoot: Config,
options: {{providerClass}}Options,
): {{providerClass}}[] {
return readProviderConfigs(configRoot).map(providerConfig => {
return new {{providerClass}}({
id: providerConfig.id,
target: providerConfig.target,
logger: options.logger,
taskRunner: options.scheduler.createScheduledTaskRunner(
providerConfig.schedule,
),
});
});
}
readonly #id: string;
readonly #target: string;
readonly #logger: LoggerService;
readonly #taskRunner: SchedulerServiceTaskRunner;
constructor(options: {
id: string;
target: string;
logger: LoggerService;
taskRunner: SchedulerServiceTaskRunner;
}) {
this.#id = options.id;
this.#target = options.target;
this.#logger = options.logger;
this.#taskRunner = options.taskRunner;
}
/** {@inheritdoc @backstage/plugin-catalog-node#EntityProvider.getProviderName} */
getProviderName() {
return `{{providerClass}}:${this.#id}`;
}
/** {@inheritdoc @backstage/plugin-catalog-node#EntityProvider.connect} */
async connect(connection: EntityProviderConnection) {
const id = `${this.getProviderName()}:refresh`;
// Schedule a refresh task to be run periodically
await this.#taskRunner.run({
id,
fn: async () => {
const logger = this.#logger.child({
taskId: id,
taskInstanceId: uuid.v4(),
});
try {
const entities = await this.read({ logger });
logger.info(`Read ${entities.length} entities`);
await connection.applyMutation({
type: 'full',
entities,
});
} catch (error) {
logger.error(`Refresh failed`, error);
}
},
});
}
/**
* Reads entities to be added to the catalog.
*/
async read(options: { logger: LoggerService }): Promise<DeferredEntity[]> {
const { logger } = options;
logger.info(`Reading entities from ${this.#target}`);
// TODO: Implement entity reading logic from the target
const entities: DeferredEntity[] = [];
return entities;
}
}
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
A CLI module that adds commands to the Backstage CLI.
_This package was created through the Backstage CLI_
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('node:path');
/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('node:fs').existsSync(
path.resolve(__dirname, '../src'),
);
if (isLocal) {
require('@backstage/cli-node/config/nodeTransform.cjs');
}
const { runCliModule } = require('@backstage/cli-node');
const cliModule = require(isLocal ? '../src/index' : '..').default;
const pkg = require('../package.json');
runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
@@ -0,0 +1,35 @@
{
"name": "{{packageName}}",
"description": "CLI module for Backstage CLI",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "cli-module"
},
"scripts": {
"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/cli-common": "{{versionQuery '@backstage/cli-common'}}",
"@backstage/cli-node": "{{versionQuery '@backstage/cli-node'}}",
"cleye": "^2.3.0"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist",
"bin"
],
"bin": "bin/{{binName}}"
}
@@ -0,0 +1,5 @@
name: cli-module
role: cli-module
description: A CLI module that adds commands to the Backstage CLI
values:
binName: 'backstage-cli-module-{{ name }}'
@@ -0,0 +1,22 @@
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const { flags } = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
name: {
type: String,
description: 'Your name',
},
},
},
undefined,
args,
);
const name = flags.name ?? 'World';
console.log(`Hello, ${name}!`);
};
@@ -0,0 +1,20 @@
/***/
/**
* CLI module for the Backstage CLI.
*
* @packageDocumentation
*/
import { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['example'],
description: 'An example command',
execute: { loader: () => import('./commands/example') },
});
},
});
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,13 @@
# {{pluginId}}
Welcome to the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -0,0 +1,11 @@
import { createDevApp } from '@backstage/dev-utils';
import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin';
createDevApp()
.registerPlugin({{ pluginVar }})
.addPage({
element: <{{ extensionName }} />,
title: 'Root Page',
path: '/{{pluginId}}',
})
.render();
@@ -0,0 +1,54 @@
{
"name": "{{packageName}}",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin",
"pluginId": "{{pluginId}}"
},
"sideEffects": false,
"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/core-components": "{{versionQuery '@backstage/core-components'}}",
"@backstage/core-plugin-api": "{{versionQuery '@backstage/core-plugin-api'}}",
"@backstage/theme": "{{versionQuery '@backstage/theme'}}",
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
"@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.61'}}",
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
"@backstage/dev-utils": "{{versionQuery '@backstage/dev-utils'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}",
"msw": "{{versionQuery 'msw' '1.0.0'}}",
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-dom": "{{versionQuery 'react-dom' '^16.13.1 || ^17.0.0 || ^18.0.0'}}",
"react-router-dom": "{{versionQuery 'react-router-dom' '^6.0.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,6 @@
name: frontend-plugin
role: frontend-plugin
description: A new frontend plugin
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
extensionName: '{{ upperFirst ( camelCase pluginId ) }}Page'
@@ -0,0 +1,28 @@
import { ExampleComponent } from './ExampleComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { screen } from '@testing-library/react';
import {
registerMswTestHooks,
renderInTestApp,
} from '@backstage/test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
registerMswTestHooks(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', async () => {
await renderInTestApp(<ExampleComponent />);
expect(
screen.getByText('Welcome to {{pluginId}}!'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
import { Typography, Grid } from '@material-ui/core';
import {
InfoCard,
Header,
Page,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core-components';
import { ExampleFetchComponent } from '../ExampleFetchComponent';
export const ExampleComponent = () => (
<Page themeId="tool">
<Header title="Welcome to {{pluginId}}!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Plugin title">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<ExampleFetchComponent />
</Grid>
</Grid>
</Content>
</Page>
);
@@ -0,0 +1 @@
export { ExampleComponent } from './ExampleComponent';
@@ -0,0 +1,19 @@
import { renderInTestApp } from '@backstage/test-utils';
import { ExampleFetchComponent } from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
it('renders the user table', async () => {
const { getAllByText, getByAltText, getByText, findByRole } =
await renderInTestApp(<ExampleFetchComponent />);
// Wait for the table to render
const table = await findByRole('table');
const nationality = getAllByText('GB');
// Assert that the table contains the expected user data
expect(table).toBeInTheDocument();
expect(getByAltText('Carolyn')).toBeInTheDocument();
expect(getByText('Carolyn Moore')).toBeInTheDocument();
expect(getByText('carolyn.moore@example.com')).toBeInTheDocument();
expect(nationality[0]).toBeInTheDocument();
});
});
@@ -0,0 +1,308 @@
import { makeStyles } from '@material-ui/core/styles';
import {
Table,
TableColumn,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import useAsync from 'react-use/lib/useAsync';
export const exampleUsers = {
results: [
{
gender: 'female',
name: {
title: 'Miss',
first: 'Carolyn',
last: 'Moore',
},
email: 'carolyn.moore@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn',
nat: 'GB',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Esma',
last: 'Berberoğlu',
},
email: 'esma.berberoglu@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma',
nat: 'TR',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Isabella',
last: 'Rhodes',
},
email: 'isabella.rhodes@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
nat: 'GB',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Derrick',
last: 'Carter',
},
email: 'derrick.carter@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick',
nat: 'IE',
},
{
gender: 'female',
name: {
title: 'Miss',
first: 'Mattie',
last: 'Lambert',
},
email: 'mattie.lambert@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie',
nat: 'AU',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Mijat',
last: 'Rakić',
},
email: 'mijat.rakic@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat',
nat: 'RS',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Javier',
last: 'Reid',
},
email: 'javier.reid@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Isabella',
last: 'Li',
},
email: 'isabella.li@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
nat: 'CA',
},
{
gender: 'female',
name: {
title: 'Mrs',
first: 'Stephanie',
last: 'Garrett',
},
email: 'stephanie.garrett@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie',
nat: 'AU',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Antonia',
last: 'Núñez',
},
email: 'antonia.nunez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia',
nat: 'ES',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Donald',
last: 'Young',
},
email: 'donald.young@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald',
nat: 'US',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Iegor',
last: 'Holodovskiy',
},
email: 'iegor.holodovskiy@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor',
nat: 'UA',
},
{
gender: 'female',
name: {
title: 'Madame',
first: 'Jessica',
last: 'David',
},
email: 'jessica.david@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica',
nat: 'CH',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Eve',
last: 'Martinez',
},
email: 'eve.martinez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve',
nat: 'FR',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Caleb',
last: 'Silva',
},
email: 'caleb.silva@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Miss',
first: 'Marcia',
last: 'Jenkins',
},
email: 'marcia.jenkins@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Mrs',
first: 'Mackenzie',
last: 'Jones',
},
email: 'mackenzie.jones@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie',
nat: 'NZ',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Jeremiah',
last: 'Gutierrez',
},
email: 'jeremiah.gutierrez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah',
nat: 'AU',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Luciara',
last: 'Souza',
},
email: 'luciara.souza@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara',
nat: 'BR',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Valgi',
last: 'da Cunha',
},
email: 'valgi.dacunha@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi',
nat: 'BR',
},
],
};
const useStyles = makeStyles({
avatar: {
height: 32,
width: 32,
borderRadius: '50%',
},
});
type User = {
gender: string; // "male"
name: {
title: string; // "Mr",
first: string; // "Duane",
last: string; // "Reed"
};
email: string; // "duane.reed@example.com"
picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane"
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable = ({ users }: DenseTableProps) => {
const classes = useStyles();
const columns: TableColumn[] = [
{ title: 'Avatar', field: 'avatar' },
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map(user => {
return {
avatar: (
<img
src={user.picture}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<Table
title="Example User List"
options=\{{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
export const ExampleFetchComponent = () => {
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
// Would use fetch in a real world example
return exampleUsers.results;
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return <DenseTable users={value || []} />;
};
@@ -0,0 +1 @@
export { ExampleFetchComponent } from './ExampleFetchComponent';
@@ -0,0 +1 @@
export { {{ pluginVar }}, {{ extensionName }} } from './plugin';
@@ -0,0 +1,7 @@
import { {{ pluginVar }} } from './plugin';
describe('{{pluginId}}', () => {
it('should export plugin', () => {
expect({{ pluginVar }}).toBeDefined();
});
});
@@ -0,0 +1,22 @@
import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
export const {{ pluginVar }} = createPlugin({
id: '{{pluginId}}',
routes: {
root: rootRouteRef,
},
});
export const {{ extensionName }} = {{ pluginVar }}.provide(
createRoutableExtension({
name: '{{ extensionName }}',
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: '{{pluginId}}',
});
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
The {{moduleId}} frontend module for the {{pluginId}} plugin.
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,35 @@
{
"name": "{{packageName}}",
"description": "The {{moduleId}} frontend module for the {{pluginId}} plugin.",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin-module",
"pluginId": "{{pluginId}}"
},
"sideEffects": false,
"scripts": {
"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/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,5 @@
name: frontend-plugin-module
role: frontend-plugin-module
description: A new frontend module that extends an existing frontend plugin
values:
moduleVar: '{{ camelCase pluginId }}Module{{ upperFirst ( camelCase moduleId ) }}'
@@ -0,0 +1 @@
export { {{ moduleVar }} as default } from './plugin';
@@ -0,0 +1,8 @@
import { createFrontendModule } from '@backstage/frontend-plugin-api';
export const {{ moduleVar }} = createFrontendModule({
pluginId: '{{ pluginId }}',
extensions: [
/* TODO */
],
});
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,20 @@
# {{pluginId}}
Welcome to the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the app in this repository, meaning you'll be able
to access it by running `yarn start` in the root directory, and then navigating
to [/{{pluginId}}](http://localhost:3000/{{pluginId}}).
This plugin is built with Backstage's [new frontend
system](https://backstage.io/docs/frontend-system/architecture/index), and you
can find more information about building plugins in the [plugin builder
documentation](https://backstage.io/docs/frontend-system/building-plugins/index).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -0,0 +1,5 @@
import { createDevApp } from '@backstage/frontend-dev-utils';
import plugin from '../src';
createDevApp({ features: [plugin] });
@@ -0,0 +1,49 @@
{
"name": "{{packageName}}",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin",
"pluginId": "{{pluginId}}"
},
"sideEffects": false,
"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/core-components": "{{versionQuery '@backstage/core-components'}}",
"@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}",
"@backstage/theme": "{{versionQuery '@backstage/theme'}}",
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
"@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.61'}}",
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/frontend-dev-utils": "{{versionQuery '@backstage/frontend-dev-utils'}}",
"@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}",
"msw": "{{versionQuery 'msw' '1.0.0'}}",
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,5 @@
name: frontend-plugin
role: frontend-plugin
description: A new frontend plugin
values:
pluginVar: '{{ camelCase pluginId }}Plugin'
@@ -0,0 +1,28 @@
import { ExampleComponent } from './ExampleComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { screen } from '@testing-library/react';
import {
registerMswTestHooks,
renderInTestApp,
} from '@backstage/frontend-test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
registerMswTestHooks(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', async () => {
await renderInTestApp(<ExampleComponent />);
expect(
screen.getByText('Welcome to {{pluginId}}!'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
import { Typography, Grid } from '@material-ui/core';
import {
InfoCard,
Header,
Page,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core-components';
import { ExampleFetchComponent } from '../ExampleFetchComponent';
export const ExampleComponent = () => (
<Page themeId="tool">
<Header title="Welcome to {{pluginId}}!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Plugin title">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<ExampleFetchComponent />
</Grid>
</Grid>
</Content>
</Page>
);
@@ -0,0 +1 @@
export { ExampleComponent } from './ExampleComponent';
@@ -0,0 +1,19 @@
import { renderInTestApp } from '@backstage/frontend-test-utils';
import { ExampleFetchComponent } from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
it('renders the user table', async () => {
const { getAllByText, getByAltText, getByText, findByRole } =
await renderInTestApp(<ExampleFetchComponent />);
// Wait for the table to render
const table = await findByRole('table');
const nationality = getAllByText('GB');
// Assert that the table contains the expected user data
expect(table).toBeInTheDocument();
expect(getByAltText('Carolyn')).toBeInTheDocument();
expect(getByText('Carolyn Moore')).toBeInTheDocument();
expect(getByText('carolyn.moore@example.com')).toBeInTheDocument();
expect(nationality[0]).toBeInTheDocument();
});
});
@@ -0,0 +1,308 @@
import { makeStyles } from '@material-ui/core/styles';
import {
Table,
TableColumn,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import useAsync from 'react-use/lib/useAsync';
export const exampleUsers = {
results: [
{
gender: 'female',
name: {
title: 'Miss',
first: 'Carolyn',
last: 'Moore',
},
email: 'carolyn.moore@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn',
nat: 'GB',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Esma',
last: 'Berberoğlu',
},
email: 'esma.berberoglu@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma',
nat: 'TR',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Isabella',
last: 'Rhodes',
},
email: 'isabella.rhodes@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
nat: 'GB',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Derrick',
last: 'Carter',
},
email: 'derrick.carter@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick',
nat: 'IE',
},
{
gender: 'female',
name: {
title: 'Miss',
first: 'Mattie',
last: 'Lambert',
},
email: 'mattie.lambert@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie',
nat: 'AU',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Mijat',
last: 'Rakić',
},
email: 'mijat.rakic@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat',
nat: 'RS',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Javier',
last: 'Reid',
},
email: 'javier.reid@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Isabella',
last: 'Li',
},
email: 'isabella.li@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella',
nat: 'CA',
},
{
gender: 'female',
name: {
title: 'Mrs',
first: 'Stephanie',
last: 'Garrett',
},
email: 'stephanie.garrett@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie',
nat: 'AU',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Antonia',
last: 'Núñez',
},
email: 'antonia.nunez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia',
nat: 'ES',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Donald',
last: 'Young',
},
email: 'donald.young@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald',
nat: 'US',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Iegor',
last: 'Holodovskiy',
},
email: 'iegor.holodovskiy@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor',
nat: 'UA',
},
{
gender: 'female',
name: {
title: 'Madame',
first: 'Jessica',
last: 'David',
},
email: 'jessica.david@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica',
nat: 'CH',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Eve',
last: 'Martinez',
},
email: 'eve.martinez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve',
nat: 'FR',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Caleb',
last: 'Silva',
},
email: 'caleb.silva@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Miss',
first: 'Marcia',
last: 'Jenkins',
},
email: 'marcia.jenkins@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia',
nat: 'US',
},
{
gender: 'female',
name: {
title: 'Mrs',
first: 'Mackenzie',
last: 'Jones',
},
email: 'mackenzie.jones@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie',
nat: 'NZ',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Jeremiah',
last: 'Gutierrez',
},
email: 'jeremiah.gutierrez@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah',
nat: 'AU',
},
{
gender: 'female',
name: {
title: 'Ms',
first: 'Luciara',
last: 'Souza',
},
email: 'luciara.souza@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara',
nat: 'BR',
},
{
gender: 'male',
name: {
title: 'Mr',
first: 'Valgi',
last: 'da Cunha',
},
email: 'valgi.dacunha@example.com',
picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi',
nat: 'BR',
},
],
};
const useStyles = makeStyles({
avatar: {
height: 32,
width: 32,
borderRadius: '50%',
},
});
type User = {
gender: string; // "male"
name: {
title: string; // "Mr",
first: string; // "Duane",
last: string; // "Reed"
};
email: string; // "duane.reed@example.com"
picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane"
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable = ({ users }: DenseTableProps) => {
const classes = useStyles();
const columns: TableColumn[] = [
{ title: 'Avatar', field: 'avatar' },
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map(user => {
return {
avatar: (
<img
src={user.picture}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<Table
title="Example User List"
options=\{{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
export const ExampleFetchComponent = () => {
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
// Would use fetch in a real world example
return exampleUsers.results;
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return <DenseTable users={value || []} />;
};
@@ -0,0 +1 @@
export { ExampleFetchComponent } from './ExampleFetchComponent';
@@ -0,0 +1 @@
export { {{ pluginVar }} as default } from './plugin';
@@ -0,0 +1,7 @@
import { {{ pluginVar }} } from './plugin';
describe('{{pluginId}}', () => {
it('should export plugin', () => {
expect({{ pluginVar }}).toBeDefined();
});
});
@@ -0,0 +1,25 @@
import {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';
export const page = PageBlueprint.make({
params: {
path: '/{{pluginId}}',
routeRef: rootRouteRef,
loader: () =>
import('./components/ExampleComponent').then(m =>
<m.ExampleComponent />,
),
},
});
export const {{ pluginVar }} = createFrontendPlugin({
pluginId: '{{pluginId}}',
extensions: [page],
routes: {
root: rootRouteRef,
}
});
@@ -0,0 +1,3 @@
import { createRouteRef } from '@backstage/frontend-plugin-api';
export const rootRouteRef = createRouteRef();
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,12 @@
# {{packageName}}
_This package was created through the Backstage CLI_.
## Installation
Install the package via Yarn:
```sh
cd <package-dir> # if within a monorepo
yarn add {{packageName}}
```
@@ -0,0 +1,29 @@
{
"name": "{{packageName}}",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "node-library"
},
"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"
},
"devDependencies": {
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
name: node-library
role: node-library
description: A library package, exporting shared functionality for Node.js environments
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
Welcome to the common package for the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,31 @@
{
"name": "{{packageName}}",
"description": "Common functionalities for the {{pluginId}} plugin",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library",
"pluginId": "{{pluginId}}"
},
"sideEffects": false,
"scripts": {
"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"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
name: plugin-common-library
role: plugin-common-library
description: A new isomorphic common plugin package
@@ -0,0 +1,19 @@
/***/
/**
* Common functionalities for the {{pluginId}} 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 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
Welcome to the Node.js library package for the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,30 @@
{
"name": "{{packageName}}",
"description": "Node.js library for the {{pluginId}} plugin",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "node-library",
"pluginId": "{{pluginId}}"
},
"scripts": {
"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"
},
"devDependencies": {
"@backstage/backend-test-utils": "{{versionQuery '@backstage/backend-test-utils'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
name: plugin-node-library
role: plugin-node-library
description: A new Node.js library plugin package
@@ -0,0 +1,18 @@
/***/
/**
* Node.js library for the {{pluginId}} plugin.
*
* @packageDocumentation
*/
// In this package you might for example export functions that
// help other plugins or modules interact with your plugin.
/**
* Does something useful.
*
* @public
*/
export function someFunction() {
// ...
}
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
Welcome to the web library package for the {{pluginId}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,42 @@
{
"name": "{{packageName}}",
"description": "Web library for the {{pluginId}} plugin",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "web-library",
"pluginId": "{{pluginId}}"
},
"sideEffects": false,
"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/core-plugin-api": "{{versionQuery '@backstage/core-plugin-api'}}",
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
name: plugin-web-library
role: plugin-web-library
description: A new web library plugin package

Some files were not shown because too many files have changed in this diff Show More