Merge branch 'master' into scalable-sidebar-new-implementation

This commit is contained in:
hiba-aldalaty
2021-11-16 09:37:43 +00:00
65 changed files with 2253 additions and 145 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/test-utils': patch
---
Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Skip empty file names when scaffolding with nunjucks
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added a scaffolder backend module template for the `create` command.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
Remove exports of unused types(`RouteOptions` and `RoutePath`).
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types.
+1
View File
@@ -65,3 +65,4 @@
| [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. |
| [Palo Alto Networks](https://www.paloaltonetworks.com) | [Jeremy Guarini](https://github.com/jeremyguarini), [Brian Lomeland](https://github.com/bbbmmmlll), [Palo Alto Networks](https://github.com/PaloAltoNetworks) | Developer portal, service catalog, documentation and tooling |
| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem |
| [Tradeshift](https://www.tradeshift.com/) | [Soren Mathiasen](https://github.com/sorenmat) | Developer Portal: documentation, monitoring, service templates, service catalog for our micro services |
+1 -1
View File
@@ -199,7 +199,7 @@ export a class that `implements` the target API, for example:
```ts
export class IgnoringErrorApi implements ErrorApi {
post(error: Error, context?: ErrorContext) {
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
// ignore error
}
}
+39
View File
@@ -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 <name> Select the thing you want to be creating upfront
--option <name>=<value> Pre-fill options for the creation process (default: [])
--scope <scope> The scope to use for new packages
--npm-registry <URL> 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`
+2 -1
View File
@@ -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 .",
@@ -263,7 +263,6 @@ export default async (cmd: Command) => {
const pluginDir = isMonoRepo
? paths.resolveTargetRoot('plugins', pluginId)
: paths.resolveTargetRoot(pluginId);
const ownerIds = parseOwnerIds(answers.owner);
const { version: pluginVersion } = isMonoRepo
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
: { version: '0.1.0' };
@@ -318,12 +317,8 @@ export default async (cmd: Command) => {
await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (ownerIds && ownerIds.length) {
await addCodeownersEntry(
codeownersPath!,
`/plugins/${pluginId}`,
ownerIds,
);
if (answers.owner) {
await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner);
}
Task.log();
+135
View File
@@ -0,0 +1,135 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'os';
import fs from 'fs-extra';
import { join as joinPath } from 'path';
import { Command } from 'commander';
import { FactoryRegistry } from '../../lib/create/FactoryRegistry';
import { paths } from '../../lib/paths';
import { assertError } from '@backstage/errors';
import { Task } from '../../lib/tasks';
function parseOptions(optionStrings: string[]): Record<string, string> {
const options: Record<string, string> = {};
for (const str of optionStrings) {
const [key] = str.split('=', 1);
const value = str.slice(key.length + 1);
if (!key || str[key.length] !== '=') {
throw new Error(
`Invalid option '${str}', must be of the format <key>=<value>`,
);
}
options[key] = value;
}
return options;
}
export default async (cmd: Command) => {
const cmdOpts = cmd.opts();
const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select);
const providedOptions = parseOptions(cmdOpts.option);
const options = await FactoryRegistry.populateOptions(
factory,
providedOptions,
);
let isMonoRepo = false;
try {
const rootPackageJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
if (rootPackageJson.workspaces) {
isMonoRepo = true;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
let defaultVersion = '0.1.0';
try {
const rootLernaJson = await fs.readJson(
paths.resolveTargetRoot('lerna.json'),
);
if (rootLernaJson.version) {
defaultVersion = rootLernaJson.version;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
const tempDirs = new Array<string>();
async function createTemporaryDirectory(name: string): Promise<string> {
const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name));
tempDirs.push(dir);
return dir;
}
let modified = false;
try {
await factory.create(options, {
isMonoRepo,
defaultVersion,
scope: cmdOpts.scope?.replace(/^@/, ''),
npmRegistry: cmdOpts.npmRegistry,
private: Boolean(cmdOpts.private),
createTemporaryDirectory,
markAsModified() {
modified = true;
},
});
Task.log();
Task.log(`🎉 Successfully created ${factory.name}`);
Task.log();
} catch (error) {
assertError(error);
Task.error(error.message);
if (modified) {
Task.log('It seems that something went wrong in the creation process 🤔');
Task.log();
Task.log(
'We have left the changes that were made intact in case you want to',
);
Task.log(
'continue manually, but you can also revert the changes and try again.',
);
Task.error(`🔥 Failed to create ${factory.name}!`);
}
} finally {
for (const dir of tempDirs) {
try {
await fs.remove(dir);
} catch (error) {
console.error(
`Failed to remove temporary directory '${dir}', ${error}`,
);
}
}
}
};
+24
View File
@@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) {
.option(...configOption)
.action(lazy(() => import('./backend/dev').then(m => m.default)));
program
.command('create')
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./create/create').then(m => m.default)));
program
.command('create-plugin')
.option(
@@ -177,9 +177,9 @@ describe('removePlugin', () => {
fse.readFileSync(mockedCodeownersPath, 'utf8'),
);
await addCodeownersEntry(
testFilePath!,
path.join('plugins', testPluginName),
['@thisIsAtestTeam', 'test@gmail.com'],
'@thisIsAtestTeam test@gmail.com',
testFilePath,
);
await removePluginFromCodeOwners(testFilePath, testPluginName);
expect(testFileContent).toBe(codeOwnersFileContent);
+24 -8
View File
@@ -16,6 +16,7 @@
import fs from 'fs-extra';
import path from 'path';
import { paths } from '../paths';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
@@ -30,14 +31,14 @@ type CodeownersEntry = {
export async function getCodeownersFilePath(
rootDir: string,
): Promise<string | undefined> {
const paths = [
const possiblePaths = [
path.join(rootDir, '.github', 'CODEOWNERS'),
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
path.join(rootDir, 'docs', 'CODEOWNERS'),
path.join(rootDir, 'CODEOWNERS'),
];
for (const p of paths) {
for (const p of possiblePaths) {
if (await fs.pathExists(p)) {
return p;
}
@@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean {
}
export function parseOwnerIds(
spaceSeparatedOwnerIds: string,
spaceSeparatedOwnerIds: string | undefined,
): string[] | undefined {
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
return undefined;
@@ -70,11 +71,24 @@ export function parseOwnerIds(
}
export async function addCodeownersEntry(
codeownersFilePath: string,
ownedPath: string,
ownerIds: string[],
): Promise<void> {
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
ownerStr: string,
codeownersFilePath?: string,
): Promise<boolean> {
const ownerIds = parseOwnerIds(ownerStr);
if (!ownerIds || ownerIds.length === 0) {
return false;
}
let filePath = codeownersFilePath;
if (!filePath) {
filePath = await getCodeownersFilePath(paths.targetRoot);
if (!filePath) {
return false;
}
}
const allLines = (await fs.readFile(filePath, 'utf8')).split('\n');
// Only keep comments from the top of the file
const commentLines = [];
@@ -117,5 +131,7 @@ export async function addCodeownersEntry(
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
await fs.writeFile(filePath, newLines.join('\n'), 'utf8');
return true;
}
@@ -0,0 +1,126 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import inquirer from 'inquirer';
import { AnyFactory, Prompt } from './types';
import * as factories from './factories';
import partition from 'lodash/partition';
function applyPromptMessageTransforms<T>(
prompt: Prompt<T>,
transforms: {
message: (msg: string) => string;
error: (msg: string) => string;
},
): Prompt<T> {
return {
...prompt,
message:
prompt.message &&
(async answers => {
if (typeof prompt.message === 'function') {
return transforms.message(await prompt.message(answers));
}
return transforms.message(await prompt.message!);
}),
validate:
prompt.validate &&
(async (...args) => {
const result = await prompt.validate!(...args);
if (typeof result === 'string') {
return transforms.error(result);
}
return result;
}),
};
}
export class FactoryRegistry {
private static factoryMap = new Map<string, AnyFactory>(
Object.values(factories).map(factory => [factory.name, factory]),
);
static async interactiveSelect(preselected?: string): Promise<AnyFactory> {
let selected = preselected;
if (!selected) {
const answers = await inquirer.prompt<{ name: string }>([
{
type: 'list',
name: 'name',
message: 'What do you want to create?',
choices: Array.from(this.factoryMap.values()).map(factory => ({
name: `${factory.name} - ${factory.description}`,
value: factory.name,
})),
},
]);
selected = answers.name;
}
const factory = this.factoryMap.get(selected);
if (!factory) {
throw new Error(`Unknown selection '${selected}'`);
}
return factory;
}
static async populateOptions(
factory: AnyFactory,
provided: Record<string, string>,
): Promise<Record<string, string>> {
let currentOptions = provided;
if (factory.optionsDiscovery) {
const discoveredOptions = await factory.optionsDiscovery();
currentOptions = {
...currentOptions,
...(discoveredOptions as Record<string, string>),
};
}
if (factory.optionsPrompts) {
const [hasAnswers, needsAnswers] = partition(
factory.optionsPrompts,
option => option.name in currentOptions,
);
for (const option of hasAnswers) {
const value = provided[option.name];
if (option.validate) {
const result = option.validate(value);
if (result !== true) {
throw new Error(`Invalid option '${option.name}'. ${result}`);
}
}
}
currentOptions = await inquirer.prompt(
needsAnswers.map(option =>
applyPromptMessageTransforms(option, {
message: chalk.blue,
error: chalk.red,
}),
),
currentOptions,
);
}
return currentOptions;
}
}
@@ -0,0 +1,115 @@
/*
* 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 { 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${sep}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${sep}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: resolvePath('/root/plugins/test-backend'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test-backend'),
optional: true,
});
});
});
@@ -0,0 +1,89 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const backendPlugin = createFactory<Options>({
name: 'backend-plugin',
description: 'A new backend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const id = `${options.id}-backend`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-backend-plugin',
values: {
id,
name,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
await Task.forItem('backend', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/backend/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,58 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Prompt } from '../../types';
import { parseOwnerIds } from '../../../codeowners';
export function pluginIdPrompt(): Prompt<{ id: string }> {
return {
type: 'input',
name: 'id',
message: 'Enter the ID of the plugin [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the ID of the plugin';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
};
}
export function ownerPrompt(): Prompt<{
owner?: string;
codeOwnersPath?: string;
}> {
return {
type: 'input',
name: 'owner',
message: 'Enter an owner to add to CODEOWNERS [optional]',
when: opts => Boolean(opts.codeOwnersPath),
validate: (value: string) => {
if (!value) {
return true;
}
const ownerIds = parseOwnerIds(value);
if (!ownerIds) {
return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).';
}
return true;
},
};
}
@@ -0,0 +1,118 @@
/*
* 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 } from 'path';
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 ..${sep}target`,
'creating temp dir',
'Executing Template:',
'templating package.json.hbs',
'copying not-templated.txt',
'templating templated.txt.hbs',
'Installing:',
`moving ..${sep}target`,
]);
await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{
"name": "my-testing-plugin",
"private": true,
"description": "testing",
"dependencies": {
"some-package": "^1.1.0",
"other-package": "^2.3.0"
}
}
`);
await expect(
fs.readFile('/target/subdir/templated.txt', 'utf8'),
).resolves.toBe('Hello testing!');
await expect(
fs.readFile('/target/subdir/not-templated.txt', 'utf8'),
).resolves.toBe('Hello {{id}}!');
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import { resolve as resolvePath, relative as relativePath } from 'path';
import { paths } from '../../../paths';
import { Task, templatingTask } from '../../../tasks';
import { Lockfile } from '../../../versioning';
import { createPackageVersionProvider } from '../../../version';
import { CreateContext } from '../../types';
export async function executePluginPackageTemplate(
ctx: CreateContext,
options: {
templateName: string;
targetDir: string;
values: Record<string, unknown>;
},
) {
const { targetDir } = options;
let lockfile: Lockfile | undefined;
try {
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
} catch {
/* ignored */
}
Task.section('Checking Prerequisites');
const shortPluginDir = relativePath(paths.targetRoot, targetDir);
await Task.forItem('availability', shortPluginDir, async () => {
if (await fs.pathExists(targetDir)) {
throw new Error(
`A package with the same plugin ID already exists at ${chalk.cyan(
shortPluginDir,
)}. Please try again with a different ID.`,
);
}
});
const tempDir = await Task.forItem('creating', 'temp dir', async () => {
return await ctx.createTemporaryDirectory('backstage-create');
});
Task.section('Executing Template');
await templatingTask(
paths.resolveOwn('templates', options.templateName),
tempDir,
options.values,
createPackageVersionProvider(lockfile),
);
// Format package.json if it exists
const pkgJsonPath = resolvePath(tempDir, 'package.json');
if (await fs.pathExists(pkgJsonPath)) {
const pkgJson = await fs.readJson(pkgJsonPath);
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
Task.section('Installing');
await Task.forItem('moving', shortPluginDir, async () => {
await fs.move(tempDir, targetDir).catch(error => {
throw new Error(
`Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`,
);
});
});
ctx.markAsModified();
}
@@ -0,0 +1,75 @@
/*
* 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.
*/
/* eslint-disable no-control-regex */
import { WriteStream } from 'tty';
import { resolve as resolvePath } from 'path';
import { paths } from '../../../paths';
export function mockPaths(options: {
ownDir?: string;
ownRoot?: string;
targetDir?: string;
targetRoot?: string;
}): void {
const { ownDir, ownRoot, targetDir, targetRoot } = options;
if (ownDir) {
paths.ownDir = ownDir;
jest
.spyOn(paths, 'resolveOwn')
.mockImplementation((...ps) => resolvePath(ownDir, ...ps));
}
if (ownRoot) {
jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot);
jest
.spyOn(paths, 'resolveOwnRoot')
.mockImplementation((...ps) => resolvePath(ownRoot, ...ps));
}
if (targetDir) {
paths.targetDir = targetDir;
jest
.spyOn(paths, 'resolveTarget')
.mockImplementation((...ps) => resolvePath(targetDir, ...ps));
}
if (targetRoot) {
jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...ps) => resolvePath(targetRoot, ...ps));
}
}
export function createMockOutputStream() {
const output = new Array<string>();
return [
output,
{
cursorTo: () => {},
clearLine: () => {},
moveCursor: () => {},
write: (msg: string) => {
let clean = msg;
// Remove terminal color escape sequences
clean = clean.replace(/\x1B\[\d\dm/g, '');
// Remove any non-ascii
clean = clean.replace(/[^\x00-\x7F]+/g, '');
clean = clean.trim();
output.push(clean);
},
} as unknown as WriteStream & { fd: any },
] as const;
}
@@ -0,0 +1,217 @@
/*
* 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 { frontendPlugin } from './frontendPlugin';
const appTsxContent = `
import { createApp } from '@backstage/app-defaults';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
</FlatRoutes>
)
`;
describe('frontendPlugin factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a frontend plugin', async () => {
mockFs({
'/root': {
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test',
'Checking Prerequisites:',
`availability plugins${sep}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${sep}test`,
'app adding dependency',
'app adding import',
]);
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'backstage-plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from 'backstage-plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
});
it('should create a frontend plugin with more options and codeowners', async () => {
mockFs({
'/root': {
CODEOWNERS: '',
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
owner: '@test-user',
});
const [, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
scope: 'internal',
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'@internal/plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from '@internal/plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
});
});
@@ -0,0 +1,129 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const frontendPlugin = createFactory<Options>({
name: 'plugin',
description: 'A new frontend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
const extensionName = `${upperFirst(camelCase(id))}Page`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-plugin',
values: {
id,
name,
extensionName,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) {
await Task.forItem('app', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/app/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
await Task.forItem('app', 'adding import', async () => {
const pluginsFilePath = paths.resolveTargetRoot(
'packages/app/src/App.tsx',
);
if (!(await fs.pathExists(pluginsFilePath))) {
return;
}
const content = await fs.readFile(pluginsFilePath, 'utf8');
const revLines = content.split('\n').reverse();
const lastImportIndex = revLines.findIndex(line =>
line.match(/ from ("|').*("|')/),
);
const lastRouteIndex = revLines.findIndex(line =>
line.match(/<\/FlatRoutes/),
);
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
const importLine = `import { ${extensionName} } from '${name}';`;
if (!content.includes(importLine)) {
revLines.splice(lastImportIndex, 0, importLine);
}
const componentLine = `<Route path="/${id}" element={<${extensionName} />} />`;
if (!content.includes(componentLine)) {
const [indentation] =
revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine);
}
const newContent = revLines.reverse().join('\n');
await fs.writeFile(pluginsFilePath, newContent, 'utf8');
}
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,20 @@
/*
* 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';
export { scaffolderModule } from './scaffolderModule';
@@ -0,0 +1,108 @@
/*
* 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 { 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${sep}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${sep}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: resolvePath('/root/plugins/test-common'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test-common'),
optional: true,
});
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const pluginCommon = createFactory<Options>({
name: 'plugin-common',
description: 'A new isomorphic common plugin package',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const suffix = `${id}-common`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${suffix}`
: `backstage-plugin-${suffix}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', suffix)
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-common-plugin-package',
values: {
id,
name,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
pluginVersion: ctx.defaultVersion,
},
});
if (options.owner) {
await addCodeownersEntry(`/plugins/${suffix}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,111 @@
/*
* 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 { scaffolderModule } from './scaffolderModule';
describe('scaffolderModule factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a scaffolder backend module package', async () => {
mockFs({
'/root': {
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(scaffolderModule, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await scaffolderModule.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating module backstage-plugin-scaffolder-backend-module-test',
'Checking Prerequisites:',
`availability plugins${sep}scaffolder-backend-module-test`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating README.md.hbs',
'templating package.json.hbs',
'copying tsconfig.json',
'templating index.ts.hbs',
'copying index.ts',
'copying example.test.ts',
'copying example.ts',
'copying index.ts',
'Installing:',
`moving plugins${sep}scaffolder-backend-module-test`,
]);
await expect(
fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'),
).resolves.toEqual(
expect.objectContaining({
name: 'backstage-plugin-scaffolder-backend-module-test',
description: 'The test module for @backstage/plugin-scaffolder-backend',
private: true,
version: '1.0.0',
}),
);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
optional: true,
});
});
});
@@ -0,0 +1,96 @@
/*
* 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 } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const scaffolderModule = createFactory<Options>({
name: 'scaffolder-module',
description:
'An module exporting custom actions for @backstage/plugin-scaffolder-backend',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [
{
type: 'input',
name: 'id',
message: 'Enter the name of the module [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the name of the module';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Module names must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
},
ownerPrompt(),
],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const slug = `scaffolder-backend-module-${id}`;
let name = `backstage-plugin-${slug}`;
if (ctx.scope) {
if (ctx.scope === 'backstage') {
name = `@backstage/plugin-${slug}`;
} else {
name = `@${ctx.scope}/backstage-plugin-${slug}`;
}
}
Task.log();
Task.log(`Creating module ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', slug)
: paths.resolveTargetRoot(`backstage-plugin-${slug}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'scaffolder-module',
values: {
id,
name,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
pluginVersion: ctx.defaultVersion,
},
});
if (options.owner) {
await addCodeownersEntry(`/plugins/${slug}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
+77
View File
@@ -0,0 +1,77 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DistinctQuestion } from 'inquirer';
export interface CreateContext {
/** The package scope to use for new packages */
scope?: string;
/** The NPM registry to use for new packages */
npmRegistry?: string;
/** Whether new packages should be marked as private */
private: boolean;
/** Whether we are creating something in a monorepo or not */
isMonoRepo: boolean;
/** The default version to use for new packages */
defaultVersion: string;
/** Creates a temporary directory. This will always be deleted after creation is done. */
createTemporaryDirectory(name: string): Promise<string>;
/** Signal that the creation process got to a point where permanent modifications were made */
markAsModified(): void;
}
export type AnyOptions = Record<string, string>;
export type Prompt<TOptions> = DistinctQuestion<TOptions> & { name: string };
export interface Factory<TOptions extends AnyOptions> {
/**
* The name used for this factory.
*/
name: string;
/**
* A description that describes what this factory creates to the user.
*/
description: string;
/**
* An optional options discovery step that is run
* before the prompts to potentially fill in some of the options.
*/
optionsDiscovery?(): Promise<Partial<TOptions>>;
/**
* Inquirer prompts that will be filled in either interactively or
* through command line arguments.
*/
optionsPrompts?: ReadonlyArray<Prompt<TOptions>>;
/**
* The main method of the factory that handles creation.
*/
create(options: TOptions, context?: CreateContext): Promise<void>;
}
export type AnyFactory = Factory<AnyOptions>;
export function createFactory<TOptions extends AnyOptions>(
config: Factory<TOptions>,
): AnyFactory {
return config as AnyFactory;
}
+83 -8
View File
@@ -18,35 +18,40 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { promisify } from 'util';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
import { assertError } from '@backstage/errors';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
export class Task {
static log(name: string = '') {
process.stdout.write(`${chalk.green(name)}\n`);
process.stderr.write(`${chalk.green(name)}\n`);
}
static error(message: string = '') {
process.stdout.write(`\n${chalk.red(message)}\n\n`);
process.stderr.write(`\n${chalk.red(message)}\n\n`);
}
static section(name: string) {
const title = chalk.green(`${name}:`);
process.stdout.write(`\n ${title}\n`);
process.stderr.write(`\n ${title}\n`);
}
static exit(code: number = 0) {
process.exit(code);
}
static async forItem(
static async forItem<T = void>(
task: string,
item: string,
taskFunc: () => Promise<void>,
): Promise<void> {
taskFunc: () => Promise<T>,
): Promise<T> {
const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH));
const spinner = ora({
@@ -56,13 +61,40 @@ export class Task {
}).start();
try {
await taskFunc();
const result = await taskFunc();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}
static async forCommand(
command: string,
options?: { cwd?: string; optional?: boolean },
) {
try {
await Task.forItem('executing', command, async () => {
await exec(command, { cwd: options?.cwd });
});
} catch (error) {
assertError(error);
if (error.stderr) {
process.stderr.write(error.stderr as Buffer);
}
if (error.stdout) {
process.stdout.write(error.stdout as Buffer);
}
if (options?.optional) {
Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`);
} else {
throw new Error(
`Failed to execute command '${chalk.cyan(command)}', ${error}`,
);
}
}
}
}
export async function templatingTask(
@@ -85,7 +117,9 @@ export async function templatingTask(
const destination = destinationFile.replace(/\.hbs$/, '');
const template = await fs.readFile(file);
const compiled = handlebars.compile(template.toString());
const compiled = handlebars.compile(template.toString(), {
strict: true,
});
const contents = compiled(
{ name: basename(destination), ...context },
{
@@ -122,3 +156,44 @@ export async function templatingTask(
}
}
}
export async function addPackageDependency(
path: string,
options: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
},
) {
try {
const pkgJson = await fs.readJson(path);
const normalize = (obj: Record<string, string>) => {
if (Object.keys(obj).length === 0) {
return undefined;
}
return Object.fromEntries(
Object.keys(obj)
.sort()
.map(key => [key, obj[key]]),
);
};
pkgJson.dependencies = normalize({
...pkgJson.dependencies,
...options.dependencies,
});
pkgJson.devDependencies = normalize({
...pkgJson.devDependencies,
...options.devDependencies,
});
pkgJson.peerDependencies = normalize({
...pkgJson.peerDependencies,
...options.peerDependencies,
});
await fs.writeJson(path, pkgJson, { spaces: 2 });
} catch (error) {
throw new Error(`Failed to add package dependencies, ${error}`);
}
}
+2
View File
@@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso
import { version as devUtils } from '@backstage/dev-utils/package.json';
import { version as testUtils } from '@backstage/test-utils/package.json';
import { version as theme } from '@backstage/theme/package.json';
import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json';
export const packageVersions: Record<string, string> = {
'@backstage/backend-common': backendCommon,
@@ -53,6 +54,7 @@ export const packageVersions: Record<string, string> = {
'@backstage/dev-utils': devUtils,
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/plugin-scaffolder-backend': scaffolderBackend,
};
export function findVersion() {
@@ -4,41 +4,43 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{/if}}
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,5 @@
# {{name}}
Welcome to the common package for the {{id}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,34 @@
{
"name": "{{name}}",
"description": "Common functionalities for the {{id}} plugin",
"version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,19 @@
/***/
/**
* Common functionalities for the {{id}} plugin.
*
* @packageDocumentation
*/
/**
* In this package you might for example declare types that are common
* between the frontend and backend plugin packages.
*/
export type CommonType = {
field: string
}
/**
* Or you might declare some common constants.
*/
export const COMMON_CONSTANT = 1
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": "."
}
}
@@ -4,10 +4,12 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.esm.js",
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,5 @@
# {{name}}
The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend).
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,37 @@
{
"name": "{{name}}",
"description": "The {{id}} module for @backstage/plugin-scaffolder-backend",
"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",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli build --output cjs,types",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}"
},
"devDependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,50 @@
/*
* 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 { PassThrough } from 'stream';
import { createAcmeExampleAction } from './example';
import { getVoidLogger } from '@backstage/backend-common';
describe('acme:example', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should call action', async () => {
const action = createAcmeExampleAction();
const logger = getVoidLogger();
jest.spyOn(logger, 'info');
await action.handler({
input: {
myParameter: 'test',
},
workspacePath: '/tmp',
logger,
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory() {
// Usage of mock-fs is recommended for testing of filesystem operations
throw new Error('Not implemented');
},
});
expect(logger.info).toHaveBeenCalledWith(
'Running example template with parameters: test',
);
});
});
@@ -0,0 +1,57 @@
/*
* 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 { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
/**
* Creates an `acme:example` Scaffolder action.
*
* @remarks
*
* See {@link https://example.com} for more information.
*
* @public
*/
export function createAcmeExampleAction() {
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
myParameter: string;
}>({
id: 'acme:example',
description: 'Runs Yeoman on an installed Yeoman generator',
schema: {
input: {
type: 'object',
required: ['myParameter'],
properties: {
myParameter: {
title: 'An example parameter',
description: 'This is the schema for our example parameter',
type: 'string',
},
},
},
},
async handler(ctx) {
ctx.logger.info(
`Running example template with parameters: ${ctx.input.myParameter}`,
);
await new Promise(resolve => setTimeout(resolve, 1000));
},
});
}
@@ -0,0 +1 @@
export { createAcmeExampleAction } from './example';
@@ -0,0 +1 @@
export * from './example';
@@ -0,0 +1,8 @@
/***/
/**
* The {{id}} module for @backstage/plugin-scaffolder-backend.
*
* @packageDocumentation
*/
export * from './actions';
@@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": "."
}
}
+8 -8
View File
@@ -28,9 +28,9 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ConfigReader } from '@backstage/config';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Error as Error_2 } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorContext } from '@backstage/core-plugin-api';
import { ErrorApiError } from '@backstage/core-plugin-api';
import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FeatureFlag } from '@backstage/core-plugin-api';
import { FeatureFlagsApi } from '@backstage/core-plugin-api';
@@ -327,11 +327,11 @@ export class ErrorAlerter implements ErrorApi {
constructor(alertApi: AlertApi, errorApi: ErrorApi);
// (undocumented)
error$(): Observable<{
error: Error_2;
context?: ErrorContext | undefined;
error: ErrorApiError;
context?: ErrorApiErrorContext | undefined;
}>;
// (undocumented)
post(error: Error, context?: ErrorContext): void;
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
}
// @public
@@ -339,10 +339,10 @@ export class ErrorApiForwarder implements ErrorApi {
// (undocumented)
error$(): Observable<{
error: Error;
context?: ErrorContext;
context?: ErrorApiErrorContext;
}>;
// (undocumented)
post(error: Error, context?: ErrorContext): void;
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
}
// @public
@@ -603,7 +603,7 @@ export type SignInResult = {
// @public
export class UnhandledErrorForwarder {
static forward(errorApi: ErrorApi, errorContext: ErrorContext): void;
static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void;
}
// @public
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api';
import {
ErrorApi,
ErrorApiError,
ErrorApiErrorContext,
AlertApi,
} from '@backstage/core-plugin-api';
/**
* Decorates an ErrorApi by also forwarding error messages
@@ -27,7 +32,7 @@ export class ErrorAlerter implements ErrorApi {
private readonly errorApi: ErrorApi,
) {}
post(error: Error, context?: ErrorContext) {
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
if (!context?.hidden) {
this.alertApi.post({ message: error.message, severity: 'error' });
}
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api';
import {
ErrorApi,
ErrorApiError,
ErrorApiErrorContext,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { PublishSubject } from '../../../lib/subjects';
@@ -26,14 +30,14 @@ import { PublishSubject } from '../../../lib/subjects';
export class ErrorApiForwarder implements ErrorApi {
private readonly subject = new PublishSubject<{
error: Error;
context?: ErrorContext;
context?: ErrorApiErrorContext;
}>();
post(error: Error, context?: ErrorContext) {
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
this.subject.next({ error, context });
}
error$(): Observable<{ error: Error; context?: ErrorContext }> {
error$(): Observable<{ error: Error; context?: ErrorApiErrorContext }> {
return this.subject;
}
}
@@ -1,4 +1,8 @@
import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api';
import {
ErrorApi,
ErrorApiError,
ErrorApiErrorContext,
} from '@backstage/core-plugin-api';
/*
* Copyright 2020 Spotify AB
@@ -25,11 +29,11 @@ export class UnhandledErrorForwarder {
/**
* Add event listener, such that unhandled errors can be forwarded using an given `ErrorApi` instance
*/
static forward(errorApi: ErrorApi, errorContext: ErrorContext) {
static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext) {
window.addEventListener(
'unhandledrejection',
(e: PromiseRejectionEvent) => {
errorApi.post(e.reason as Error, errorContext);
errorApi.post(e.reason as ErrorApiError, errorContext);
},
);
}
+19 -21
View File
@@ -404,23 +404,31 @@ export interface ElementCollection {
}): ElementCollection;
}
// @public
type Error_2 = {
name: string;
message: string;
stack?: string;
};
// @public @deprecated (undocumented)
type Error_2 = ErrorApiError;
export { Error_2 as Error };
// @public
export type ErrorApi = {
post(error: Error_2, context?: ErrorContext): void;
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
error$(): Observable_2<{
error: Error_2;
context?: ErrorContext;
error: ErrorApiError;
context?: ErrorApiErrorContext;
}>;
};
// @public
export type ErrorApiError = {
name: string;
message: string;
stack?: string;
};
// @public
export type ErrorApiErrorContext = {
hidden?: boolean;
};
// @public
export const errorApiRef: ApiRef<ErrorApi>;
@@ -431,10 +439,8 @@ export type ErrorBoundaryFallbackProps = {
resetError: () => void;
};
// @public
export type ErrorContext = {
hidden?: boolean;
};
// @public @deprecated (undocumented)
export type ErrorContext = ErrorApiErrorContext;
// @public
export type Extension<T> = {
@@ -703,14 +709,6 @@ export type RouteFunc<Params extends AnyParams> = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
// @public
export type RouteOptions = {
exact?: boolean;
};
// @public
export type RoutePath = string;
// @public
export type RouteRef<Params extends AnyParams = any> = {
$$routeRefType: 'absolute';
@@ -23,22 +23,34 @@ import { Observable } from '@backstage/types';
*
* @public
*/
export type Error = {
export type ErrorApiError = {
name: string;
message: string;
stack?: string;
};
/**
* @public
* @deprecated Use ErrorApiError instead
*/
export type Error = ErrorApiError;
/**
* Provides additional information about an error that was posted to the application.
*
* @public
*/
export type ErrorContext = {
export type ErrorApiErrorContext = {
// If set to true, this error should not be displayed to the user. Defaults to false.
hidden?: boolean;
};
/**
* @public
* @deprecated Use ErrorApiErrorContext instead
*/
export type ErrorContext = ErrorApiErrorContext;
/**
* The error API is used to report errors to the app, and display them to the user.
*
@@ -62,12 +74,15 @@ export type ErrorApi = {
/**
* Post an error for handling by the application.
*/
post(error: Error, context?: ErrorContext): void;
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
/**
* Observe errors posted by other parts of the application.
*/
error$(): Observable<{ error: Error; context?: ErrorContext }>;
error$(): Observable<{
error: ErrorApiError;
context?: ErrorApiErrorContext;
}>;
};
/**
@@ -25,6 +25,4 @@ export type {
PluginConfig,
PluginHooks,
PluginOutput,
RouteOptions,
RoutePath,
} from './types';
@@ -17,23 +17,6 @@
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
import { AnyApiFactory } from '../apis/system';
/**
* Route configuration.
*
* @public
*/
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
exact?: boolean;
};
/**
* Type alias for paths.
*
* @public
*/
export type RoutePath = string;
/**
* Replace with using {@link RouteRef}s.
*
+1 -1
View File
@@ -25,7 +25,7 @@ jest.mock('./lib/tasks');
beforeAll(() => {
mockFs({
'package.json': '', // required by `findPaths(__dirname)`
[`${__dirname}/package.json`]: '', // required by `findPaths(__dirname)`
'templates/': mockFs.load(path.resolve(__dirname, '../templates/')),
});
});
+10 -2
View File
@@ -19,7 +19,12 @@ import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import recursive from 'recursive-readdir';
import { basename, dirname, resolve as resolvePath } from 'path';
import {
basename,
dirname,
resolve as resolvePath,
relative as relativePath,
} from 'path';
import { exec as execCb } from 'child_process';
import { packageVersions } from './versions';
import { promisify } from 'util';
@@ -85,7 +90,10 @@ export async function templatingTask(
});
for (const file of files) {
const destinationFile = file.replace(templateDir, destinationDir);
const destinationFile = resolvePath(
destinationDir,
relativePath(templateDir, file),
);
await fs.ensureDir(dirname(destinationFile));
if (file.endsWith('.hbs')) {
+7 -6
View File
@@ -7,7 +7,8 @@ import { AnalyticsApi } from '@backstage/core-plugin-api';
import { AnalyticsEvent } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorContext } from '@backstage/core-plugin-api';
import { ErrorApiError } from '@backstage/core-plugin-api';
import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { ReactElement } from 'react';
@@ -27,8 +28,8 @@ export type CollectedLogs<T extends LogFuncs> = {
// @public
export type ErrorWithContext = {
error: Error;
context?: ErrorContext;
error: ErrorApiError;
context?: ErrorApiErrorContext;
};
// @public @deprecated (undocumented)
@@ -109,13 +110,13 @@ export class MockErrorApi implements ErrorApi {
constructor(options?: MockErrorApiOptions);
// (undocumented)
error$(): Observable<{
error: Error;
context?: ErrorContext;
error: ErrorApiError;
context?: ErrorApiErrorContext;
}>;
// (undocumented)
getErrors(): ErrorWithContext[];
// (undocumented)
post(error: Error, context?: ErrorContext): void;
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
// (undocumented)
waitForError(pattern: RegExp, timeoutMs?: number): Promise<ErrorWithContext>;
}
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api';
import {
ErrorApi,
ErrorApiError,
ErrorApiErrorContext,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
/**
@@ -27,12 +31,12 @@ export type MockErrorApiOptions = {
};
/**
* ErrorWithContext contains error and ErrorContext
* ErrorWithContext contains error and ErrorApiErrorContext
* @public
*/
export type ErrorWithContext = {
error: Error;
context?: ErrorContext;
error: ErrorApiError;
context?: ErrorApiErrorContext;
};
type Waiter = {
@@ -59,7 +63,7 @@ export class MockErrorApi implements ErrorApi {
constructor(private readonly options: MockErrorApiOptions = {}) {}
post(error: Error, context?: ErrorContext) {
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
if (this.options.collect) {
this.errors.push({ error, context });
@@ -76,7 +80,10 @@ export class MockErrorApi implements ErrorApi {
throw new Error(`MockErrorApi received unexpected error, ${error}`);
}
error$(): Observable<{ error: Error; context?: ErrorContext }> {
error$(): Observable<{
error: ErrorApiError;
context?: ErrorApiErrorContext;
}> {
return nullObservable;
}
+1
View File
@@ -1290,6 +1290,7 @@ export class NextCatalogBuilder {
locationService: LocationService;
router: Router;
}>;
getDefaultProcessors(): CatalogProcessor[];
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
replaceEntityPolicies(policies: EntityPolicy[]): NextCatalogBuilder;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -144,6 +144,7 @@ describe('fetch:template', () => {
name: 'test-project',
count: 1234,
itemList: ['first', 'second', 'third'],
showDummyFile: false,
},
});
@@ -163,6 +164,10 @@ describe('fetch:template', () => {
},
'.${{ values.name }}': '${{ values.itemList | dump }}',
'a-binary-file.png': aBinaryFile,
'{% if values.showDummyFile %}dummy-file.txt{% else %}{% endif %}':
'dummy file',
'${{ "dummy-file2.txt" if values.showDummyFile else "" }}':
'some dummy file',
},
});
@@ -181,6 +186,18 @@ describe('fetch:template', () => {
);
});
it('skips empty filename', async () => {
await expect(
fs.pathExists(`${workspacePath}/target/dummy-file.txt`),
).resolves.toEqual(false);
});
it('skips empty filename syntax #2', async () => {
await expect(
fs.pathExists(`${workspacePath}/target/dummy-file2.txt`),
).resolves.toEqual(false);
});
it('copies files with no templating in names or content successfully', async () => {
await expect(
fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'),
@@ -241,6 +241,11 @@ export function createFetchTemplateAction(options: {
localOutputPath = templater.renderString(localOutputPath, context);
}
const outputPath = resolvePath(outputDir, localOutputPath);
// variables have been expanded to make an empty file name
// this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip
if (outputDir === outputPath) {
continue;
}
if (!renderContents && !extension) {
ctx.logger.info(
+3 -3
View File
@@ -23142,9 +23142,9 @@ prettier@^1.19.1:
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
prettier@^2.2.1:
version "2.4.0"
resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz#85bdfe0f70c3e777cf13a4ffff39713ca6f64cba"
integrity sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==
version "2.4.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c"
integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==
prettier@~2.2.1:
version "2.2.1"