Merge pull request #27934 from minkimcello/mk/cli-templates
Declarative CLI Templates
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
---
|
||||
'@backstage/cli': minor
|
||||
---
|
||||
|
||||
The `new` command is now powered by a new template system that allows you to define your own templates in a declarative way, as well as import existing templates from external sources. See the [CLI templates documentation](https://backstage.io/docs/tooling/cli/templates) for more information.
|
||||
|
||||
The following flags for the `new` command have been deprecated and will be removed in a future release:
|
||||
|
||||
- `--license=<license>`: Configure the global `license` instead.
|
||||
- `--no-private`: Configure the global `private` instead.
|
||||
- `--baseVersion=<version>`: Configure the global `version` instead.
|
||||
- `--npmRegistry=<url>`: Configure the global `publishRegistry` instead.
|
||||
- `--scope=<scope>`: Configure the global `namePrefix` and/or `namePluginInfix` instead.
|
||||
|
||||
As part of this change the template IDs and their options have changed. The following backwards compatibility mappings for the `--select` and `--option` flags are enabled when using the default set of templates, but they will also be removed in the future:
|
||||
|
||||
- `--select=plugin` is mapped to `--select=frontend-plugin` instead.
|
||||
- `--option=id=<id>` is mapped to `--option=pluginId=<id>` instead.
|
||||
@@ -495,8 +495,8 @@ want to have an isomorphic package that houses these types. Within the Backstage
|
||||
main repo the package naming pattern of `<plugin>-common` is used for isomorphic
|
||||
packages, and you may choose to adopt this pattern as well.
|
||||
|
||||
You can generate an isomorphic plugin package by running:`yarn new --select plugin-common`
|
||||
or you can run `yarn new` and then select "plugin-common" from the list of options
|
||||
You can generate an isomorphic plugin package by running: `yarn new` and then
|
||||
select "plugin-common" from the list of options
|
||||
|
||||
There's at this point no existing templates for generating isomorphic plugins
|
||||
using the `@backstage/cli`. Perhaps the simplest way to get started right now is
|
||||
|
||||
@@ -495,8 +495,8 @@ want to have an isomorphic package that houses these types. Within the Backstage
|
||||
main repo the package naming pattern of `<plugin>-common` is used for isomorphic
|
||||
packages, and you may choose to adopt this pattern as well.
|
||||
|
||||
You can generate an isomorphic plugin package by running:`yarn new --select plugin-common`
|
||||
or you can run `yarn new` and then select "plugin-common" from the list of options
|
||||
You can generate an isomorphic plugin package by running: `yarn new` and then
|
||||
selecting "plugin-common" from the list of options
|
||||
|
||||
There's at this point no existing templates for generating isomorphic plugins
|
||||
using the `@backstage/cli`. Perhaps the simplest way to get started right now is
|
||||
@@ -520,7 +520,7 @@ validate entities of our new kind. Just like with the definition package, you
|
||||
can find inspiration in for example the existing
|
||||
[ScaffolderEntitiesProcessor](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend-module-scaffolder-entity-model/src/processor/ScaffolderEntitiesProcessor.ts).
|
||||
|
||||
The custom processor should be created as a separate module for the catalog plugin. For information on how to set that up, see the [plugin docs](../../plugins/backend-plugin.md#creating-a-backend-plugin). Use `yarn new --select backend-module` instead to create a module. For our case, the module ID will be `foobar` and the plugin ID will be `catalog`.
|
||||
The custom processor should be created as a separate module for the catalog plugin. For information on how to set that up, see the [plugin docs](../../plugins/backend-plugin.md#creating-a-backend-plugin). Use `yarn new` and select `backend-module` instead to create a module. For our case, the module ID will be `foobar` and the plugin ID will be `catalog`.
|
||||
|
||||
We also provide a high-level example of what a catalog process for a custom
|
||||
entity might look like:
|
||||
|
||||
@@ -72,7 +72,7 @@ putting all extensions like this in a backend module package of their own in the
|
||||
`plugins` folder of your Backstage repo:
|
||||
|
||||
```sh
|
||||
yarn new --select backend-module --option id=catalog
|
||||
yarn new --select backend-module --option pluginId=catalog
|
||||
```
|
||||
|
||||
The class will have this basic structure:
|
||||
@@ -650,7 +650,7 @@ putting all extensions like this in a backend module package of their own in the
|
||||
`plugins` folder of your Backstage repo:
|
||||
|
||||
```sh
|
||||
yarn new --select backend-module --option id=catalog
|
||||
yarn new --select backend-module --option pluginId=catalog
|
||||
```
|
||||
|
||||
The class will have this basic structure:
|
||||
|
||||
@@ -10,18 +10,12 @@ Backstage repository.
|
||||
## Creating a Backend Plugin
|
||||
|
||||
A new, bare-bones backend plugin package can be created by issuing the following
|
||||
command in your Backstage repository root:
|
||||
command in your Backstage repository root and selecting `backend-plugin`:
|
||||
|
||||
```sh
|
||||
yarn new --select backend-plugin
|
||||
yarn new
|
||||
```
|
||||
|
||||
Please also see the `--help` flag for the `new` command for some
|
||||
further options that are available, notably the `--scope` and `--no-private`
|
||||
flags that control naming and publishing of the newly created package. Your repo
|
||||
root `package.json` will probably also have some default values already set up
|
||||
for these.
|
||||
|
||||
You will be asked to supply a name for the plugin. This is an identifier that
|
||||
will be part of the NPM package name, so make it short and containing only
|
||||
lowercase characters separated by dashes, for example `carmen`, if it's a
|
||||
|
||||
@@ -15,9 +15,11 @@ invoking the
|
||||
from the root of your project.
|
||||
|
||||
```bash
|
||||
yarn new --select plugin
|
||||
yarn new
|
||||
```
|
||||
|
||||
And then select `frontend-plugin`.
|
||||
|
||||

|
||||
|
||||
This will create a new Backstage Plugin based on the ID that was provided. It
|
||||
|
||||
@@ -21,7 +21,8 @@ should have a separate package in a folder, which represents your plugin.
|
||||
Example:
|
||||
|
||||
```
|
||||
$ yarn new --select plugin
|
||||
$ yarn new
|
||||
# Select `frontend-plugin`
|
||||
> ? Enter an ID for the plugin [required] my-plugin
|
||||
> ? Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional]
|
||||
|
||||
|
||||
@@ -258,30 +258,28 @@ it is possible to pre-select what you want to create using the `--select` flag,
|
||||
and provide options using `--option`, for example:
|
||||
|
||||
```bash
|
||||
backstage-cli new --select plugin --option id=foo
|
||||
backstage-cli new --select plugin --option pluginId=foo
|
||||
```
|
||||
|
||||
This command is typically added as script in the root `package.json` to be
|
||||
executed with `yarn new`, using options that are appropriate for the organization
|
||||
that owns the app repo. For example you may have it set up like this:
|
||||
executed with `yarn new`. For example you may have it set up like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"new": "backstage-cli new --scope internal --no-private --npm-registry https://acme.org/npm"
|
||||
"new": "backstage-cli new"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `new` command comes with a default collection of plugins/packages, however,
|
||||
you can customize this list and even create your own CLI templates. For more
|
||||
information see [CLI Templates](./04-templates.md).
|
||||
|
||||
```text
|
||||
Usage: backstage-cli create [options]
|
||||
Usage: backstage-cli new
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
id: templates
|
||||
title: CLI Templates
|
||||
description: Overview of the new CLI Declarative Templates
|
||||
---
|
||||
|
||||
The behavior of the `backstage-cli new` command is configurable through your root `package.json`, and you can also create and add custom CLI templates to suit your needs.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "root",
|
||||
"backstage": {
|
||||
"cli": {
|
||||
"new": {
|
||||
"globals": {
|
||||
"license": "MIT",
|
||||
"namePrefix": "@my-org/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `globals` - Configures input for all generated packages and plugins.
|
||||
- `version` - Sets the value of the `version` field in `package.json` of all generated packages. Defaults to `Apache-2.0`.
|
||||
- `license` - Sets the value of the `license` field in `package.json` of all generated packages. Defaults to `0.1.0`.
|
||||
- `private` - Sets the value of the `private` field in `package.json` of all generated packages. Defaults to `true`.
|
||||
- `publishRegistry` - Sets the value of the `publishConfig.registry` field in `package.json` of all generated packages.
|
||||
- `namePrefix` - The prefix used to generate the full package name. Defaults to `@internal/`.
|
||||
- `namePluginInfix` - The infix used to generate the full package name for plugin packages. Defaults to `plugin-`.
|
||||
- `templates` - Specifies custom templates.
|
||||
- See [Installing custom templates](#installing-custom-templates) and [Creating your own CLI templates](#creating-your-own-cli-templates) for more information.
|
||||
|
||||
The generated package name is based on the `namePrefix` and `namePluginInfix` globals, as well as the "base name" which is derived from the package role and user input. For plugin packages the final package name will be `<namePrefix><namePluginInfix><baseName>`, and for other packages it will be `<namePrefix><baseName>`.
|
||||
|
||||
For example, if you want your plugin frontend packages to end up with the name `@acme/backstage-plugin-<pluginId>`, you should use the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "root",
|
||||
"backstage": {
|
||||
"cli": {
|
||||
"new": {
|
||||
"globals": {
|
||||
"namePrefix": "@acme/",
|
||||
"namePluginInfix": "backstage-plugin-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installing custom templates
|
||||
|
||||
Custom templates can be installed from local directories. To install a template you add it to the `backstage.cli.new.templates` configuration array in your root `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "root",
|
||||
"backstage": {
|
||||
"cli": {
|
||||
"new": {
|
||||
"templates": ["./templates/custom-plugin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry in the `templates` array should be a relative path that points to a directory containing a `portable-template.yaml` file. If the path starts with `./` it will be used as is, otherwise it will be resolved as a module within `node_modules`.
|
||||
|
||||
When defining the `templates` array it will override the default set of templates. If you want to keep using one of the build-in templates in the Backstage CLI you can reference them directly within the CLI package. This following is the full list of built-in templates:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "root",
|
||||
"backstage": {
|
||||
"cli": {
|
||||
"new": {
|
||||
"templates": [
|
||||
"@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/scaffolder-backend-module"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating your own CLI templates
|
||||
|
||||
Each template lives in its own directory and must have a `portable-template.yaml` file that describes the template. The template directory can also contain any files that should be templated or copied to the generated package.
|
||||
|
||||
Start by creating `portable-template.yaml` in a new directory somewhere in your project, in this example we're using `./templates/custom-plugin/portable-template.yaml`:
|
||||
|
||||
```yaml title="in templates/custom-plugin/portable-template.yaml"
|
||||
name: custom-plugin
|
||||
role: frontend-plugin
|
||||
description: Description of my CLI template # optional
|
||||
values: # optional
|
||||
pluginVar: '{{ camelCase pluginId }}Plugin'
|
||||
```
|
||||
|
||||
The following properties are supported:
|
||||
|
||||
- `name` **(required)** - The name of your template, used by the user to select it.
|
||||
- `role` **(required)** - The role of the template, similar to package role. See [Template Roles](#template-roles) for more details.
|
||||
- `description` - A description of the type of package that this template produces.
|
||||
- `values` - A map of additional values that will be present during templating. The values are themselves templated and can reference other values. If the key matches any of the user prompts, such as `pluginId`, the value will be used directly instead of prompting the user.
|
||||
|
||||
Next, add any other files you want to be part of the template to the same directory. All files will be copied as is, except any files with a `.hbs` extension. They will be treated as [Handlebars](https://handlebarsjs.com/) templates and will be rendered with the values from the `portable-template.yaml` file as well as additional prompts such as `pluginId`. For example, you could create a `src/index.ts` file with the following content:
|
||||
|
||||
```typescript title="in templates/custom-plugin/src/index.ts.hbs"
|
||||
export function getPluginId() {
|
||||
return '{{ pluginId }}';
|
||||
}
|
||||
```
|
||||
|
||||
If you'd like to see more examples, you can find all the default templates and their yaml files [here](https://github.com/backstage/backstage/tree/master/packages/cli/templates).
|
||||
|
||||
Once your template is ready, [add it to your config](#installing-custom-templates), and you should now be able to select it when running `yarn new`.
|
||||
|
||||
### Template Roles
|
||||
|
||||
The `role` property in the template yaml file is used to determine what input will be gathered for the template, as well as what actions will be taken after the new package has been created. The following roles are supported:
|
||||
|
||||
| Role | Prompts | Output Directory | Additional Actions |
|
||||
| :----------------------- | :--------------------- | :--------------- | :-------------------------------------------------------------------------------- |
|
||||
| `frontend-plugin` | `pluginId` | `plugins` | Add dependency to `packages/app` and entry to `packages/backend/src/App.tsx` |
|
||||
| `frontend-plugin-module` | `pluginId`, `moduleId` | `plugins` | Add dependency to `packages/app` |
|
||||
| `backend-plugin` | `pluginId` | `plugins` | Add dependency to `packages/backend` and entry to `packages/backend/src/index.ts` |
|
||||
| `backend-plugin-module` | `pluginId`, `moduleId` | `plugins` | Add dependency to `packages/backend` and entry to `packages/backend/src/index.ts` |
|
||||
| `web-library` | `name` | `packages` | none |
|
||||
| `node-library` | `name` | `packages` | none |
|
||||
| `common-library` | `name` | `packages` | none |
|
||||
| `plugin-web-library` | `pluginId` | `plugins` | none |
|
||||
| `plugin-node-library` | `pluginId` | `plugins` | none |
|
||||
| `plugin-common-library` | `pluginId` | `plugins` | none |
|
||||
@@ -30,7 +30,7 @@ title: Adding Custom Plugin to Existing Monorepo App
|
||||
# The Skeleton Plugin
|
||||
|
||||
1. Start by using the built-in creator. From the terminal and root of your
|
||||
project run: `yarn new --select plugin`
|
||||
project run: `yarn new` and select `frontend-plugin`.
|
||||
1. Enter a plugin ID. I used `github-playground`
|
||||
1. When the process finishes, let's start the backend:
|
||||
`yarn --cwd packages/backend start`
|
||||
|
||||
@@ -242,6 +242,10 @@ const config: Config = {
|
||||
from: '/docs/local-dev/cli-overview/',
|
||||
to: '/docs/tooling/cli/overview/',
|
||||
},
|
||||
{
|
||||
from: '/docs/local-dev/cli-templates/',
|
||||
to: '/docs/tooling/cli/templates/',
|
||||
},
|
||||
{
|
||||
from: '/docs/local-dev/linking-local-packages/',
|
||||
to: '/docs/tooling/local-dev/linking-local-packages',
|
||||
|
||||
@@ -479,6 +479,7 @@ module.exports = {
|
||||
'tooling/cli/overview',
|
||||
'tooling/cli/build-system',
|
||||
'tooling/cli/commands',
|
||||
'tooling/cli/templates',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Local Development',
|
||||
|
||||
+11
-1
@@ -6,6 +6,16 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage"
|
||||
},
|
||||
"backstage": {
|
||||
"cli": {
|
||||
"new": {
|
||||
"globals": {
|
||||
"private": false,
|
||||
"namePrefix": "@backstage/"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
@@ -33,7 +43,7 @@
|
||||
"lint:docs": "node ./scripts/check-docs-quality",
|
||||
"lint:peer-deps": "backstage-repo-tools peer-deps",
|
||||
"lint:type-deps": "backstage-repo-tools type-deps",
|
||||
"new": "backstage-cli new --scope backstage --baseVersion 0.0.0 --no-private",
|
||||
"new": "backstage-cli new",
|
||||
"prepare": "husky",
|
||||
"prettier:check": "prettier --check .",
|
||||
"prettier:fix": "prettier --write .",
|
||||
|
||||
@@ -179,6 +179,7 @@ Usage: backstage-cli new [options]
|
||||
Options:
|
||||
--select <name>
|
||||
--option <name>=<value>
|
||||
--skip-install
|
||||
--scope <scope>
|
||||
--npm-registry <URL>
|
||||
--baseVersion <version>
|
||||
|
||||
@@ -159,7 +159,8 @@
|
||||
"yargs": "^16.2.0",
|
||||
"yml-loader": "^2.1.0",
|
||||
"yn": "^4.0.0",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.22.4",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
|
||||
@@ -257,6 +257,10 @@ export function registerCommands(program: Command) {
|
||||
(opt, arr: string[]) => [...arr, opt],
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
'--skip-install',
|
||||
`Skips running 'yarn install' and 'yarn lint --fix'`,
|
||||
)
|
||||
.option('--scope <scope>', 'The scope to use for new packages')
|
||||
.option(
|
||||
'--npm-registry <URL>',
|
||||
|
||||
@@ -14,17 +14,66 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { join as joinPath } from 'path';
|
||||
import { OptionValues } from 'commander';
|
||||
import { FactoryRegistry } from '../../lib/new/FactoryRegistry';
|
||||
import { isMonoRepo } from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { Task } from '../../lib/tasks';
|
||||
import { createNewPackage } from '../../lib/new/createNewPackage';
|
||||
|
||||
function parseOptions(optionStrings: string[]): Record<string, string> {
|
||||
type ArgOptions = {
|
||||
option: string[];
|
||||
select?: string;
|
||||
skipInstall: boolean;
|
||||
private?: boolean;
|
||||
npmRegistry?: string;
|
||||
scope?: string;
|
||||
license?: string;
|
||||
baseVersion?: string;
|
||||
};
|
||||
|
||||
export default async (opts: ArgOptions) => {
|
||||
const {
|
||||
option: rawArgOptions,
|
||||
select: preselectedTemplateId,
|
||||
skipInstall,
|
||||
scope,
|
||||
private: isPrivate,
|
||||
...otherGlobals
|
||||
} = opts;
|
||||
|
||||
const prefilledParams = parseParams(rawArgOptions);
|
||||
|
||||
let pluginInfix: string | undefined = undefined;
|
||||
let packagePrefix: string | undefined = undefined;
|
||||
if (scope) {
|
||||
const backstagePrefix = scope.startsWith('backstage') ? '' : 'backstage-';
|
||||
packagePrefix = scope.includes('/')
|
||||
? `@${scope}${backstagePrefix}`
|
||||
: `@${scope}/${backstagePrefix}`;
|
||||
pluginInfix = scope.includes('backstage') ? 'plugin-' : 'backstage-plugin-';
|
||||
}
|
||||
|
||||
if (
|
||||
isPrivate === false || // set to false with --no-private flag
|
||||
Object.values(otherGlobals).filter(Boolean).length !== 0
|
||||
) {
|
||||
console.warn(
|
||||
`Global template configuration via CLI flags is deprecated, see https://backstage.io/docs/cli/new for information on how to configure package templating`,
|
||||
);
|
||||
}
|
||||
|
||||
await createNewPackage({
|
||||
prefilledParams,
|
||||
preselectedTemplateId,
|
||||
configOverrides: {
|
||||
license: otherGlobals.license,
|
||||
version: otherGlobals.baseVersion,
|
||||
private: isPrivate,
|
||||
publishRegistry: otherGlobals.npmRegistry,
|
||||
packageNamePrefix: packagePrefix,
|
||||
packageNamePluginInfix: pluginInfix,
|
||||
},
|
||||
skipInstall,
|
||||
});
|
||||
};
|
||||
|
||||
function parseParams(optionStrings: string[]): Record<string, string> {
|
||||
const options: Record<string, string> = {};
|
||||
|
||||
for (const str of optionStrings) {
|
||||
@@ -40,81 +89,3 @@ function parseOptions(optionStrings: string[]): Record<string, string> {
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const factory = await FactoryRegistry.interactiveSelect(opts.select);
|
||||
|
||||
const providedOptions = parseOptions(opts.option);
|
||||
const options = await FactoryRegistry.populateOptions(
|
||||
factory,
|
||||
providedOptions,
|
||||
);
|
||||
|
||||
let defaultVersion = '0.1.0';
|
||||
if (opts.baseVersion) {
|
||||
defaultVersion = opts.baseVersion;
|
||||
} else {
|
||||
const lernaVersion = await fs
|
||||
.readJson(paths.resolveTargetRoot('lerna.json'))
|
||||
.then(pkg => pkg.version)
|
||||
.catch(() => undefined);
|
||||
if (lernaVersion) {
|
||||
defaultVersion = lernaVersion;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const license = opts.license ?? 'Apache-2.0';
|
||||
|
||||
let modified = false;
|
||||
try {
|
||||
await factory.create(options, {
|
||||
isMonoRepo: await isMonoRepo(),
|
||||
defaultVersion,
|
||||
license,
|
||||
scope: opts.scope?.replace(/^@/, ''),
|
||||
npmRegistry: opts.npmRegistry,
|
||||
private: Boolean(opts.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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import * as run from '../../lib/run';
|
||||
import migrate from './migrate';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import { expectLogsToMatch } from '../../lib/new/factories/common/testUtils';
|
||||
|
||||
// Remove log coloring to simplify log matching
|
||||
jest.mock('chalk', () => ({
|
||||
@@ -52,6 +51,10 @@ jest.mock('../../lib/run', () => {
|
||||
};
|
||||
});
|
||||
|
||||
function expectLogsToMatch(recievedLogs: String[], expected: String[]): void {
|
||||
expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort());
|
||||
}
|
||||
|
||||
describe('versions:migrate', () => {
|
||||
mockDir = createMockDirectory();
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* 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, { Answers } from 'inquirer';
|
||||
import { AnyFactory, Prompt } from './types';
|
||||
import * as factories from './factories';
|
||||
import partition from 'lodash/partition';
|
||||
|
||||
function applyPromptMessageTransforms<T extends Answers>(
|
||||
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,56 @@
|
||||
/*
|
||||
* 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 {
|
||||
collectPortableTemplateInput,
|
||||
loadPortableTemplate,
|
||||
loadPortableTemplateConfig,
|
||||
selectTemplateInteractively,
|
||||
} from './preparation';
|
||||
import { executePortableTemplate } from './execution';
|
||||
import { PortableTemplateConfig, PortableTemplateParams } from './types';
|
||||
|
||||
export type CreateNewPackageOptions = {
|
||||
preselectedTemplateId?: string;
|
||||
configOverrides: Partial<PortableTemplateConfig>;
|
||||
prefilledParams: PortableTemplateParams;
|
||||
skipInstall?: boolean;
|
||||
};
|
||||
|
||||
export async function createNewPackage(options: CreateNewPackageOptions) {
|
||||
const config = await loadPortableTemplateConfig({
|
||||
overrides: options.configOverrides,
|
||||
});
|
||||
|
||||
const selectedTemplate = await selectTemplateInteractively(
|
||||
config,
|
||||
options.preselectedTemplateId,
|
||||
);
|
||||
const template = await loadPortableTemplate(selectedTemplate);
|
||||
|
||||
const input = await collectPortableTemplateInput({
|
||||
config,
|
||||
template,
|
||||
prefilledParams: options.prefilledParams,
|
||||
});
|
||||
|
||||
await executePortableTemplate({
|
||||
config,
|
||||
template,
|
||||
input,
|
||||
skipInstall: options.skipInstall,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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/scaffolder-backend-module',
|
||||
];
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 handlebars from 'handlebars';
|
||||
import { PortableTemplateParams } from '../types';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
import lowerCase from 'lodash/lowerCase';
|
||||
import snakeCase from 'lodash/snakeCase';
|
||||
import startCase from 'lodash/startCase';
|
||||
import upperCase from 'lodash/upperCase';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import lowerFirst from 'lodash/lowerFirst';
|
||||
import { Lockfile } from '../../versioning';
|
||||
import { paths } from '../../paths';
|
||||
import { createPackageVersionProvider } from '../../version';
|
||||
|
||||
const builtInHelpers = {
|
||||
camelCase,
|
||||
kebabCase,
|
||||
lowerCase,
|
||||
snakeCase,
|
||||
startCase,
|
||||
upperCase,
|
||||
upperFirst,
|
||||
lowerFirst,
|
||||
};
|
||||
|
||||
type CreatePortableTemplaterOptions = {
|
||||
values?: PortableTemplateParams;
|
||||
templatedValues?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class PortableTemplater {
|
||||
static async create(options: CreatePortableTemplaterOptions = {}) {
|
||||
let lockfile: Lockfile | undefined;
|
||||
try {
|
||||
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
const versionProvider = createPackageVersionProvider(lockfile);
|
||||
|
||||
const templater = new PortableTemplater(
|
||||
{
|
||||
versionQuery(name: string, versionHint: string | unknown) {
|
||||
return versionProvider(
|
||||
name,
|
||||
typeof versionHint === 'string' ? versionHint : undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
options.values ?? {},
|
||||
);
|
||||
|
||||
if (options.templatedValues) {
|
||||
templater.appendTemplatedValues(options.templatedValues);
|
||||
}
|
||||
|
||||
return templater;
|
||||
}
|
||||
|
||||
readonly #templater: typeof handlebars;
|
||||
#values: PortableTemplateParams;
|
||||
|
||||
private constructor(
|
||||
helpers: handlebars.HelperDeclareSpec,
|
||||
values: PortableTemplateParams,
|
||||
) {
|
||||
this.#templater = handlebars.create();
|
||||
|
||||
this.#templater.registerHelper(builtInHelpers);
|
||||
|
||||
if (helpers) {
|
||||
this.#templater.registerHelper(helpers);
|
||||
}
|
||||
|
||||
this.#values = values;
|
||||
}
|
||||
|
||||
template(content: string): string {
|
||||
return this.#templater.compile(content, {
|
||||
strict: true,
|
||||
})(this.#values);
|
||||
}
|
||||
|
||||
appendTemplatedValues(record: Record<string, string>): void {
|
||||
const newValues = Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [key, this.template(value)]),
|
||||
);
|
||||
this.#values = { ...this.#values, ...newValues };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { assertError } from '@backstage/errors';
|
||||
import { addCodeownersEntry } from '../../codeowners';
|
||||
import { Task } from '../../tasks';
|
||||
import {
|
||||
PortableTemplate,
|
||||
PortableTemplateConfig,
|
||||
PortableTemplateInput,
|
||||
} from '../types';
|
||||
import { installNewPackage } from './installNewPackage';
|
||||
import { writeTemplateContents } from './writeTemplateContents';
|
||||
|
||||
type ExecuteNewTemplateOptions = {
|
||||
config: PortableTemplateConfig;
|
||||
template: PortableTemplate;
|
||||
input: PortableTemplateInput;
|
||||
skipInstall?: boolean;
|
||||
};
|
||||
|
||||
export async function executePortableTemplate(
|
||||
options: ExecuteNewTemplateOptions,
|
||||
) {
|
||||
const { template, input } = options;
|
||||
|
||||
let modified = false;
|
||||
try {
|
||||
const { targetDir } = await Task.forItem(
|
||||
'templating',
|
||||
input.packagePath,
|
||||
() => writeTemplateContents(template, input),
|
||||
);
|
||||
|
||||
modified = true;
|
||||
|
||||
await installNewPackage(input);
|
||||
|
||||
if (input.owner) {
|
||||
await addCodeownersEntry(targetDir, input.owner);
|
||||
}
|
||||
|
||||
if (!options.skipInstall) {
|
||||
await Task.forCommand('yarn install', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(`🎉 Successfully created ${template.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 ${template.name}!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
* 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.
|
||||
@@ -14,12 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { frontendPlugin } from './frontendPlugin';
|
||||
export { backendPlugin } from './backendPlugin';
|
||||
export { backendModule } from './backendModule';
|
||||
export { nodeLibraryPackage } from './nodeLibraryPackage';
|
||||
export { webLibraryPackage } from './webLibraryPackage';
|
||||
export { pluginCommon } from './pluginCommon';
|
||||
export { pluginNode } from './pluginNode';
|
||||
export { pluginWeb } from './pluginWeb';
|
||||
export { scaffolderModule } from './scaffolderModule';
|
||||
export { executePortableTemplate } from './executePortableTemplate';
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2024 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 upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { paths } from '../../paths';
|
||||
import { Task } from '../../tasks';
|
||||
import { PortableTemplateInput } from '../types';
|
||||
|
||||
export async function installNewPackage(input: PortableTemplateInput) {
|
||||
switch (input.roleParams.role) {
|
||||
case 'web-library':
|
||||
case 'node-library':
|
||||
case 'common-library':
|
||||
case 'plugin-web-library':
|
||||
case 'plugin-node-library':
|
||||
case 'plugin-common-library':
|
||||
return; // No installation action needed for library packages
|
||||
case 'frontend-plugin':
|
||||
await addDependency(input, 'package/app/package.json');
|
||||
await tryAddFrontendLegacy(input);
|
||||
return;
|
||||
case 'frontend-plugin-module':
|
||||
await addDependency(input, 'package/app/package.json');
|
||||
return;
|
||||
case 'backend-plugin':
|
||||
await addDependency(input, 'package/backend/package.json');
|
||||
await tryAddBackend(input);
|
||||
return;
|
||||
case 'backend-plugin-module':
|
||||
await addDependency(input, 'package/backend/package.json');
|
||||
await tryAddBackend(input);
|
||||
return;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported role ${(input.roleParams as { role: string }).role}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function addDependency(input: PortableTemplateInput, path: string) {
|
||||
const pkgJsonPath = paths.resolveTargetRoot(path);
|
||||
|
||||
const pkgJson = await fs.readJson(pkgJsonPath).catch(error => {
|
||||
if (error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!pkgJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pkgJson.dependencies = {
|
||||
...pkgJson.dependencies,
|
||||
[input.packageName]: `workspace:^`,
|
||||
};
|
||||
|
||||
await fs.writeJson(path, pkgJson, { spaces: 2 });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to add package dependencies, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAddFrontendLegacy(input: PortableTemplateInput) {
|
||||
const { roleParams } = input;
|
||||
if (roleParams.role !== 'frontend-plugin') {
|
||||
throw new Error(
|
||||
'add-frontend-legacy can only be used for frontend plugins',
|
||||
);
|
||||
}
|
||||
|
||||
const appDefinitionPath = paths.resolveTargetRoot('packages/app/src/App.tsx');
|
||||
if (!(await fs.pathExists(appDefinitionPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.forItem('app', 'adding import', async () => {
|
||||
const content = await fs.readFile(appDefinitionPath, '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 extensionName = upperFirst(`${camelCase(roleParams.pluginId)}Page`);
|
||||
const importLine = `import { ${extensionName} } from '${input.packageName}';`;
|
||||
if (!content.includes(importLine)) {
|
||||
revLines.splice(lastImportIndex, 0, importLine);
|
||||
}
|
||||
|
||||
const componentLine = `<Route path="/${roleParams.pluginId}" 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(appDefinitionPath, newContent, 'utf8');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function tryAddBackend(input: PortableTemplateInput) {
|
||||
const backendIndexPath = paths.resolveTargetRoot(
|
||||
'packages/backend/src/index.ts',
|
||||
);
|
||||
if (!(await fs.pathExists(backendIndexPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.forItem('backend', `adding ${input.packageName}`, async () => {
|
||||
const content = await fs.readFile(backendIndexPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const backendAddLine = `backend.add(import('${input.packageName}'));`;
|
||||
|
||||
const backendStartIndex = lines.findIndex(line =>
|
||||
line.match(/backend.start/),
|
||||
);
|
||||
|
||||
if (backendStartIndex !== -1) {
|
||||
const [indentation] = lines[backendStartIndex].match(/^\s*/)!;
|
||||
lines.splice(backendStartIndex, 0, `${indentation}${backendAddLine}`);
|
||||
|
||||
const newContent = lines.join('\n');
|
||||
await fs.writeFile(backendIndexPath, newContent, 'utf8');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 { relative as relativePath } from 'node:path';
|
||||
import { writeTemplateContents } from './writeTemplateContents';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { paths } from '../../paths';
|
||||
|
||||
const baseConfig = {
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
};
|
||||
|
||||
describe('writeTemplateContents', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.spyOn(paths, 'resolveTargetRoot')
|
||||
.mockImplementation((...args) => mockDir.resolve(...args));
|
||||
});
|
||||
|
||||
it('should write an empty template', async () => {
|
||||
const { targetDir } = await writeTemplateContents(
|
||||
{
|
||||
name: 'test',
|
||||
files: [],
|
||||
role: 'frontend-plugin',
|
||||
values: {},
|
||||
},
|
||||
{
|
||||
...baseConfig,
|
||||
roleParams: { role: 'frontend-plugin', pluginId: 'test' },
|
||||
packageName: '@internal/plugin-test',
|
||||
packagePath: 'plugins/plugin-test',
|
||||
},
|
||||
);
|
||||
|
||||
expect(relativePath(mockDir.path, targetDir)).toBe('plugins/plugin-test');
|
||||
expect(mockDir.content()).toEqual({});
|
||||
});
|
||||
|
||||
it('should write template with various files', async () => {
|
||||
await writeTemplateContents(
|
||||
{
|
||||
name: 'test',
|
||||
files: [
|
||||
{
|
||||
content: 'test',
|
||||
path: 'test.txt',
|
||||
},
|
||||
{
|
||||
content: 'id={{ pluginId}}',
|
||||
path: 'plugin.txt',
|
||||
syntax: 'handlebars',
|
||||
},
|
||||
{
|
||||
content: '{"x":1}',
|
||||
path: 'test.json',
|
||||
},
|
||||
],
|
||||
role: 'frontend-plugin',
|
||||
values: {},
|
||||
},
|
||||
{
|
||||
...baseConfig,
|
||||
roleParams: { role: 'frontend-plugin', pluginId: 'test' },
|
||||
packageName: '@internal/plugin-test',
|
||||
packagePath: 'out',
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
out: {
|
||||
'test.txt': 'test',
|
||||
'plugin.txt': 'id=test',
|
||||
'test.json': '{"x":1}',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 { dirname, resolve as resolvePath } from 'path';
|
||||
|
||||
import { paths } from '../../paths';
|
||||
import { PortableTemplate, PortableTemplateInput } from '../types';
|
||||
import { ForwardedError, InputError } from '@backstage/errors';
|
||||
import { isMonoRepo as getIsMonoRepo } from '@backstage/cli-node';
|
||||
import { PortableTemplater } from './PortableTemplater';
|
||||
|
||||
export async function writeTemplateContents(
|
||||
template: PortableTemplate,
|
||||
input: PortableTemplateInput,
|
||||
): Promise<{ targetDir: string }> {
|
||||
const targetDir = paths.resolveTargetRoot(input.packagePath);
|
||||
|
||||
if (await fs.pathExists(targetDir)) {
|
||||
throw new InputError(`Package '${input.packagePath}' already exists`);
|
||||
}
|
||||
|
||||
try {
|
||||
const isMonoRepo = await getIsMonoRepo();
|
||||
|
||||
const { role, ...roleValues } = input.roleParams;
|
||||
|
||||
const templater = await PortableTemplater.create({
|
||||
values: {
|
||||
...roleValues,
|
||||
packageName: input.packageName,
|
||||
},
|
||||
templatedValues: template.values,
|
||||
});
|
||||
|
||||
if (!isMonoRepo) {
|
||||
await fs.writeJson(
|
||||
resolvePath(targetDir, 'tsconfig.json'),
|
||||
{
|
||||
extends: '@backstage/cli/config/tsconfig.json',
|
||||
include: ['src', 'dev', 'migrations'],
|
||||
exclude: ['node_modules'],
|
||||
compilerOptions: {
|
||||
outDir: 'dist-types',
|
||||
rootDir: '.',
|
||||
},
|
||||
},
|
||||
{ spaces: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of template.files) {
|
||||
const destPath = resolvePath(targetDir, file.path);
|
||||
await fs.ensureDir(dirname(destPath));
|
||||
|
||||
let content =
|
||||
file.syntax === 'handlebars'
|
||||
? templater.template(file.content)
|
||||
: file.content;
|
||||
|
||||
// Automatically inject input values into package.json
|
||||
if (file.path === 'package.json') {
|
||||
try {
|
||||
content = injectPackageJsonInput(input, content);
|
||||
} catch (error) {
|
||||
throw new ForwardedError(
|
||||
'Failed to transform templated package.json',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(destPath, content).catch(error => {
|
||||
throw new ForwardedError(`Failed to copy file to ${destPath}`, error);
|
||||
});
|
||||
}
|
||||
|
||||
return { targetDir };
|
||||
} catch (error) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true, maxRetries: 10 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function injectPackageJsonInput(
|
||||
input: PortableTemplateInput,
|
||||
content: string,
|
||||
) {
|
||||
const pkgJson = JSON.parse(content);
|
||||
|
||||
const toAdd = new Array<[name: string, value: unknown]>();
|
||||
|
||||
if (pkgJson.version) {
|
||||
pkgJson.version = input.version;
|
||||
} else {
|
||||
toAdd.push(['version', input.version]);
|
||||
}
|
||||
if (pkgJson.license) {
|
||||
pkgJson.license = input.license;
|
||||
} else {
|
||||
toAdd.push(['license', input.license]);
|
||||
}
|
||||
if (input.private) {
|
||||
if (pkgJson.private === false) {
|
||||
pkgJson.private = true;
|
||||
} else if (!pkgJson.private) {
|
||||
toAdd.push(['private', true]);
|
||||
}
|
||||
} else {
|
||||
delete pkgJson.private;
|
||||
}
|
||||
|
||||
if (input.publishRegistry) {
|
||||
if (pkgJson.publishConfig) {
|
||||
pkgJson.publishConfig = {
|
||||
...pkgJson.publishConfig,
|
||||
registry: input.publishRegistry,
|
||||
};
|
||||
} else {
|
||||
toAdd.push(['publishConfig', { registry: input.publishRegistry }]);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(pkgJson);
|
||||
|
||||
const nameIndex = entries.findIndex(([name]) => name === 'name');
|
||||
if (nameIndex === -1) {
|
||||
throw new Error('templated package.json does not contain a name field');
|
||||
}
|
||||
|
||||
entries.splice(nameIndex + 1, 0, ...toAdd);
|
||||
|
||||
return JSON.stringify(Object.fromEntries(entries), null, 2);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { backendModule } from './backendModule';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const backendIndexTsContent = `
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.start();
|
||||
`;
|
||||
|
||||
describe('backendModule factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a backend plugin', async () => {
|
||||
mockDir.setContent({
|
||||
packages: {
|
||||
backend: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'index.ts': backendIndexTsContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(backendModule, {
|
||||
id: 'test',
|
||||
moduleId: 'tester-two',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await backendModule.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating backend module backstage-plugin-test-backend-module-tester-two',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-backend-module-tester-two`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'templating module.ts.hbs',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-backend-module-tester-two`,
|
||||
'backend adding dependency',
|
||||
'backend adding module',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('packages/backend/src/index.ts'), 'utf8'),
|
||||
).resolves.toBe(`
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('backstage-plugin-test-backend-module-tester-two'));
|
||||
backend.start();
|
||||
`);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('packages/backend/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-test-backend-module-tester-two': '^1.0.0',
|
||||
},
|
||||
});
|
||||
const moduleFile = await fs.readFile(
|
||||
mockDir.resolve('plugins/test-backend-module-tester-two/src/module.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(moduleFile).toContain(
|
||||
`const testModuleTesterTwo = createBackendModule({`,
|
||||
);
|
||||
expect(moduleFile).toContain(`pluginId: 'test',`);
|
||||
expect(moduleFile).toContain(`moduleId: 'tester-two',`);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('plugins/test-backend-module-tester-two'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test-backend-module-tester-two'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { addPackageDependency, addToBackend, Task } from '../../tasks';
|
||||
import {
|
||||
moduleIdIdPrompt,
|
||||
ownerPrompt,
|
||||
pluginIdPrompt,
|
||||
} from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const backendModule = createFactory<Options>({
|
||||
name: 'backend-module',
|
||||
description:
|
||||
'A new backend module that extends an existing backend plugin with additional features',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), moduleIdIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id: pluginId, moduleId } = options;
|
||||
const dirName = `${pluginId}-backend-module-${moduleId}`;
|
||||
const name = resolvePackageName({
|
||||
baseName: dirName,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating backend module ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', dirName)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${dirName}`);
|
||||
|
||||
const moduleCamelCase = camelCase(moduleId);
|
||||
const modulePascalCase =
|
||||
moduleCamelCase[0].toUpperCase() + moduleCamelCase.slice(1);
|
||||
const moduleVar = `${camelCase(pluginId)}Module${modulePascalCase}`;
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-backend-module',
|
||||
values: {
|
||||
pluginId,
|
||||
moduleId,
|
||||
name,
|
||||
moduleVar,
|
||||
packageVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${dirName}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { backendPlugin } from './backendPlugin';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const backendIndexTsContent = `
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.start();
|
||||
`;
|
||||
|
||||
describe('backendPlugin factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a backend plugin', async () => {
|
||||
mockDir.setContent({
|
||||
packages: {
|
||||
backend: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'index.ts': backendIndexTsContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
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'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating backend plugin backstage-plugin-test-backend',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-backend`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating plugin.ts.hbs',
|
||||
'templating plugin.test.ts.hbs',
|
||||
'copying index.ts',
|
||||
'copying setupTests.ts',
|
||||
'copying router.ts',
|
||||
'copying router.test.ts',
|
||||
'copying createTodoListService.ts',
|
||||
'copying types.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-backend`,
|
||||
'backend adding dependency',
|
||||
'backend adding plugin',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('packages/backend/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-test-backend': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('packages/backend/src/index.ts'), 'utf8'),
|
||||
).resolves.toBe(`
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('backstage-plugin-test-backend'));
|
||||
backend.start();
|
||||
`);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('plugins/test-backend'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test-backend'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { addPackageDependency, addToBackend, Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
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;
|
||||
const pluginId = `${id}-backend`;
|
||||
const name = resolvePackageName({
|
||||
baseName: pluginId,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', pluginId)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${pluginId}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-backend-plugin',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
pluginVar: `${camelCase(id)}Plugin`,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'plugin',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* 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 moduleIdIdPrompt(): Prompt<{ moduleId: string }> {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'moduleId',
|
||||
message: 'Enter the ID of the module [required]',
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'Please enter the ID of the module';
|
||||
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
||||
return 'Module IDs must be lowercase and contain only letters, digits, and dashes.';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ownerPrompt(): Prompt<{
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
}> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './testUtils';
|
||||
import { CreateContext } from '../../types';
|
||||
import { executePluginPackageTemplate } from './tasks';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
mockPaths({
|
||||
ownDir: mockDir.resolve('own'),
|
||||
targetRoot: mockDir.resolve('root'),
|
||||
});
|
||||
|
||||
describe('executePluginPackageTemplate', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute template', async () => {
|
||||
mockDir.setContent({
|
||||
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: mockDir.resolve('target'),
|
||||
values: {
|
||||
id: 'testing',
|
||||
makePrivate: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(modified).toBe(true);
|
||||
expectLogsToMatch(output, [
|
||||
'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(mockDir.resolve('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(mockDir.resolve('target/subdir/templated.txt'), 'utf8'),
|
||||
).resolves.toBe('Hello testing!');
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('target/subdir/not-templated.txt'), 'utf8'),
|
||||
).resolves.toBe('Hello {{id}}!');
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* 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),
|
||||
ctx.isMonoRepo,
|
||||
);
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic
|
||||
export function expectLogsToMatch(
|
||||
recievedLogs: String[],
|
||||
expected: String[],
|
||||
): void {
|
||||
expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort());
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 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 { resolvePackageName } from './util';
|
||||
|
||||
describe('resolvePackageName', () => {
|
||||
it('should generate correct name without scope', () => {
|
||||
expect(resolvePackageName({ baseName: 'test', plugin: true })).toEqual(
|
||||
'backstage-plugin-test',
|
||||
);
|
||||
expect(resolvePackageName({ baseName: 'test', plugin: false })).toEqual(
|
||||
'test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct name for backstage scope', () => {
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'backstage',
|
||||
plugin: true,
|
||||
}),
|
||||
).toEqual('@backstage/plugin-test');
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'backstage',
|
||||
plugin: false,
|
||||
}),
|
||||
).toEqual('@backstage/test');
|
||||
});
|
||||
|
||||
it('should generate correct name for custom scope', () => {
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'custom',
|
||||
plugin: true,
|
||||
}),
|
||||
).toEqual('@custom/backstage-plugin-test');
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'custom',
|
||||
plugin: false,
|
||||
}),
|
||||
).toEqual('@custom/test');
|
||||
});
|
||||
|
||||
it('should generate correct name for custom scope and custom prefix', () => {
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'custom/myapp.',
|
||||
plugin: true,
|
||||
}),
|
||||
).toEqual('@custom/myapp.backstage-plugin-test');
|
||||
expect(
|
||||
resolvePackageName({
|
||||
baseName: 'test',
|
||||
scope: 'custom/myapp.',
|
||||
plugin: false,
|
||||
}),
|
||||
).toEqual('@custom/myapp.test');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 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 const resolvePackageName = (options: {
|
||||
baseName: string;
|
||||
scope?: string;
|
||||
plugin: boolean;
|
||||
}) => {
|
||||
const { baseName, scope, plugin } = options;
|
||||
if (scope) {
|
||||
if (plugin) {
|
||||
const pluginName = scope.startsWith('backstage')
|
||||
? 'plugin'
|
||||
: 'backstage-plugin';
|
||||
return scope.includes('/')
|
||||
? `@${scope}${pluginName}-${baseName}`
|
||||
: `@${scope}/${pluginName}-${baseName}`;
|
||||
}
|
||||
return scope.includes('/')
|
||||
? `@${scope}${baseName}`
|
||||
: `@${scope}/${baseName}`;
|
||||
}
|
||||
|
||||
return plugin ? `backstage-plugin-${baseName}` : baseName;
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { frontendPlugin } from './frontendPlugin';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const appTsxContent = `
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
|
||||
const router = (
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</FlatRoutes>
|
||||
)
|
||||
`;
|
||||
|
||||
describe('frontendPlugin factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a frontend plugin', async () => {
|
||||
mockDir.setContent({
|
||||
packages: {
|
||||
app: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'App.tsx': appTsxContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
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'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating frontend plugin backstage-plugin-test',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'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(mockDir.resolve('packages/app/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('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: mockDir.resolve('plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a frontend plugin with more options and codeowners', async () => {
|
||||
mockDir.setContent({
|
||||
CODEOWNERS: '',
|
||||
packages: {
|
||||
app: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'App.tsx': appTsxContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
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'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('packages/app/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'@internal/backstage-plugin-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'),
|
||||
).resolves.toBe(`
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
import { TestPage } from '@internal/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: mockDir.resolve('plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { addPackageDependency, Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
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 = resolvePackageName({
|
||||
baseName: id,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
const extensionName = `${upperFirst(camelCase(id))}Page`;
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating frontend 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,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { join as joinPath } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { nodeLibraryPackage } from './nodeLibraryPackage';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('nodeLibraryPackage factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a node library package', async () => {
|
||||
const expectedNodeLibraryPackageName = 'test';
|
||||
|
||||
mockDir.setContent({
|
||||
packages: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, {
|
||||
id: 'test', // name of node library package
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await nodeLibraryPackage.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
`Creating node-library package ${expectedNodeLibraryPackageName}`,
|
||||
'Checking Prerequisites:',
|
||||
`availability ${joinPath('packages', expectedNodeLibraryPackageName)}`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'Installing:',
|
||||
`moving ${joinPath('packages', expectedNodeLibraryPackageName)}`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(
|
||||
mockDir.resolve(
|
||||
'packages',
|
||||
expectedNodeLibraryPackageName,
|
||||
'package.json',
|
||||
),
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: expectedNodeLibraryPackageName,
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a node library plugin with options and codeowners', async () => {
|
||||
const expectedNodeLibraryPackageName = 'test';
|
||||
|
||||
mockDir.setContent({
|
||||
CODEOWNERS: '',
|
||||
packages: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, {
|
||||
id: 'test',
|
||||
owner: '@backstage/test-owners',
|
||||
});
|
||||
|
||||
const [, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await nodeLibraryPackage.create(options, {
|
||||
scope: 'internal',
|
||||
private: true,
|
||||
isMonoRepo: false,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve(expectedNodeLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve(expectedNodeLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const nodeLibraryPackage = createFactory<Options>({
|
||||
name: 'node-library',
|
||||
description:
|
||||
'A new node-library package, exporting shared functionality for backend plugins and modules',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const name = resolvePackageName({
|
||||
baseName: id,
|
||||
scope: ctx.scope,
|
||||
plugin: false,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating node-library package ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('packages', id)
|
||||
: paths.resolveTargetRoot(`${id}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'node-library-package',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/packages/${id}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { pluginCommon } from './pluginCommon';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('pluginCommon factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a common plugin package', async () => {
|
||||
mockDir.setContent({
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
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'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating common plugin package backstage-plugin-test-common',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-common`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-common`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('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: mockDir.resolve('plugins/test-common'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test-common'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
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 = resolvePackageName({
|
||||
baseName: suffix,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating common plugin package ${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,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { pluginNode } from './pluginNode';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('pluginNode factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a node plugin package', async () => {
|
||||
mockDir.setContent({
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(pluginNode, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await pluginNode.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating Node.js plugin library backstage-plugin-test-node',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-node`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-node`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('plugins/test-node/package.json')),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'backstage-plugin-test-node',
|
||||
description: 'Node.js library for the test plugin',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('plugins/test-node'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test-node'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const pluginNode = createFactory<Options>({
|
||||
name: 'plugin-node',
|
||||
description: 'A new Node.js library plugin package',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const suffix = `${id}-node`;
|
||||
const name = resolvePackageName({
|
||||
baseName: suffix,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating Node.js plugin library ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', suffix)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-node-plugin-package',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { pluginWeb } from './pluginWeb';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('pluginWeb factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a react plugin package', async () => {
|
||||
mockDir.setContent({
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(pluginWeb, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await pluginWeb.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating web plugin library backstage-plugin-test-react',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-react`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'copying index.ts',
|
||||
'copying ExampleComponent.test.tsx',
|
||||
'copying ExampleComponent.tsx',
|
||||
'copying index.ts',
|
||||
'copying index.ts',
|
||||
'copying index.ts',
|
||||
'copying useExample.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-react`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('plugins/test-react/package.json')),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'backstage-plugin-test-react',
|
||||
description: 'Web library for the test plugin',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('plugins/test-react'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/test-react'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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 { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const pluginWeb = createFactory<Options>({
|
||||
name: 'plugin-react',
|
||||
description: 'A new web library plugin package',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const suffix = `${id}-react`;
|
||||
const name = resolvePackageName({
|
||||
baseName: suffix,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating web plugin library ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', suffix)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-react-plugin-package',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* 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 { sep } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { scaffolderModule } from './scaffolderModule';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const backendIndexTsContent = `
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.start();
|
||||
`;
|
||||
|
||||
describe('scaffolderModule factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a scaffolder backend module package', async () => {
|
||||
mockDir.setContent({
|
||||
packages: {
|
||||
backend: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'index.ts': backendIndexTsContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
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),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
'Creating module backstage-plugin-scaffolder-backend-module-test',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}scaffolder-backend-module-test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying example.test.ts',
|
||||
'copying example.ts',
|
||||
'copying module.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}scaffolder-backend-module-test`,
|
||||
'backend adding dependency',
|
||||
'backend adding module',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('packages/backend/src/index.ts'), 'utf8'),
|
||||
).resolves.toBe(`
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('backstage-plugin-scaffolder-backend-module-test'));
|
||||
backend.start();
|
||||
`);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('packages/backend/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-scaffolder-backend-module-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readJson(
|
||||
mockDir.resolve('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: mockDir.resolve('plugins/scaffolder-backend-module-test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* 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 { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { CreateContext, createFactory } from '../types';
|
||||
import { addPackageDependency, addToBackend, Task } from '../../tasks';
|
||||
import { ownerPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
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}`;
|
||||
|
||||
const name = resolvePackageName({
|
||||
baseName: slug,
|
||||
scope: ctx.scope,
|
||||
plugin: true,
|
||||
});
|
||||
|
||||
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,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { join as joinPath } from 'path';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import {
|
||||
createMockOutputStream,
|
||||
expectLogsToMatch,
|
||||
mockPaths,
|
||||
} from './common/testUtils';
|
||||
import { webLibraryPackage } from './webLibraryPackage';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('webLibraryPackage factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: mockDir.path,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a web library package', async () => {
|
||||
const expectedwebLibraryPackageName = 'test';
|
||||
|
||||
mockDir.setContent({
|
||||
packages: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(webLibraryPackage, {
|
||||
id: 'test', // name of web library package
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await webLibraryPackage.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expectLogsToMatch(output, [
|
||||
`Creating web-library package ${expectedwebLibraryPackageName}`,
|
||||
'Checking Prerequisites:',
|
||||
`availability ${joinPath('packages', expectedwebLibraryPackageName)}`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'Installing:',
|
||||
`moving ${joinPath('packages', expectedwebLibraryPackageName)}`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson(
|
||||
mockDir.resolve(
|
||||
'packages',
|
||||
expectedwebLibraryPackageName,
|
||||
'package.json',
|
||||
),
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: expectedwebLibraryPackageName,
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve('packages', expectedwebLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve('packages', expectedwebLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a web library plugin with options and codeowners', async () => {
|
||||
const expectedwebLibraryPackageName = 'test';
|
||||
|
||||
mockDir.setContent({
|
||||
CODEOWNERS: '',
|
||||
packages: {},
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(webLibraryPackage, {
|
||||
id: 'test',
|
||||
owner: '@backstage/test-owners',
|
||||
});
|
||||
|
||||
const [, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await webLibraryPackage.create(options, {
|
||||
scope: 'internal',
|
||||
private: true,
|
||||
isMonoRepo: false,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
license: 'Apache-2.0',
|
||||
});
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: mockDir.resolve(expectedwebLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: mockDir.resolve(expectedwebLibraryPackageName),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const webLibraryPackage = createFactory<Options>({
|
||||
name: 'web-library',
|
||||
description:
|
||||
'A new web-library package, exporting shared functionality for frontend plugins',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const name = resolvePackageName({
|
||||
baseName: id,
|
||||
scope: ctx.scope,
|
||||
plugin: false,
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating web-library package ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('packages', id)
|
||||
: paths.resolveTargetRoot(`${id}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'web-library-package',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
license: ctx.license,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/packages/${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,152 @@
|
||||
/*
|
||||
* Copyright 2024 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 inquirer from 'inquirer';
|
||||
import { PortableTemplateConfig } from '../types';
|
||||
import { collectPortableTemplateInput } from './collectPortableTemplateInput';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
|
||||
describe('collectTemplateParams', () => {
|
||||
const baseOptions = {
|
||||
config: {
|
||||
isUsingDefaultTemplates: false,
|
||||
templatePointers: [],
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
packageNamePrefix: '@internal/',
|
||||
packageNamePluginInfix: 'plugin-',
|
||||
} satisfies PortableTemplateConfig,
|
||||
template: {
|
||||
name: 'test',
|
||||
role: 'frontend-plugin' as const,
|
||||
files: [],
|
||||
values: {},
|
||||
},
|
||||
prefilledParams: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should prompt for missing parameters', async () => {
|
||||
jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ pluginId: 'other' });
|
||||
|
||||
await expect(
|
||||
collectPortableTemplateInput({
|
||||
...baseOptions,
|
||||
prefilledParams: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
roleParams: {
|
||||
role: 'frontend-plugin',
|
||||
pluginId: 'other',
|
||||
},
|
||||
owner: undefined,
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
packageName: '@internal/plugin-other',
|
||||
packagePath: 'plugins/other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pick up prefilled parameters', async () => {
|
||||
await expect(
|
||||
collectPortableTemplateInput({
|
||||
...baseOptions,
|
||||
prefilledParams: {
|
||||
pluginId: 'test1',
|
||||
owner: 'me',
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
roleParams: {
|
||||
role: 'frontend-plugin',
|
||||
pluginId: 'test1',
|
||||
},
|
||||
owner: 'me',
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
packageName: '@internal/plugin-test1',
|
||||
packagePath: 'plugins/test1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pick up template values', async () => {
|
||||
await expect(
|
||||
collectPortableTemplateInput({
|
||||
...baseOptions,
|
||||
template: {
|
||||
...baseOptions.template,
|
||||
values: {
|
||||
pluginId: 'test2',
|
||||
owner: 'me',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
roleParams: {
|
||||
role: 'frontend-plugin',
|
||||
pluginId: 'test2',
|
||||
},
|
||||
owner: 'me',
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
packageName: '@internal/plugin-test2',
|
||||
packagePath: 'plugins/test2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map deprecated id param to pluginId', async () => {
|
||||
const logs = await withLogCollector(async () => {
|
||||
await expect(
|
||||
collectPortableTemplateInput({
|
||||
...baseOptions,
|
||||
config: {
|
||||
...baseOptions.config,
|
||||
isUsingDefaultTemplates: true,
|
||||
},
|
||||
prefilledParams: {
|
||||
id: 'test3',
|
||||
owner: 'me',
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
roleParams: {
|
||||
role: 'frontend-plugin',
|
||||
pluginId: 'test3',
|
||||
},
|
||||
owner: 'me',
|
||||
version: '0.1.0',
|
||||
license: 'Apache-2.0',
|
||||
private: true,
|
||||
packageName: '@internal/plugin-test3',
|
||||
packagePath: 'plugins/test3',
|
||||
});
|
||||
});
|
||||
expect(logs).toEqual({
|
||||
error: [],
|
||||
log: [],
|
||||
warn: [
|
||||
`DEPRECATION WARNING: The 'id' parameter is deprecated, use 'pluginId' instead`,
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 inquirer, { DistinctQuestion } from 'inquirer';
|
||||
import { getCodeownersFilePath, parseOwnerIds } from '../../codeowners';
|
||||
import { paths } from '../../paths';
|
||||
import {
|
||||
PortableTemplateConfig,
|
||||
PortableTemplateInput,
|
||||
PortableTemplateInputRoleParams,
|
||||
PortableTemplateParams,
|
||||
PortableTemplateRole,
|
||||
} from '../types';
|
||||
import { PortableTemplate } from '../types';
|
||||
import { resolvePackageParams } from './resolvePackageParams';
|
||||
|
||||
type CollectTemplateParamsOptions = {
|
||||
config: PortableTemplateConfig;
|
||||
template: PortableTemplate;
|
||||
prefilledParams: PortableTemplateParams;
|
||||
};
|
||||
|
||||
export async function collectPortableTemplateInput(
|
||||
options: CollectTemplateParamsOptions,
|
||||
): Promise<PortableTemplateInput> {
|
||||
const { config, template, prefilledParams } = options;
|
||||
|
||||
const codeOwnersFilePath = await getCodeownersFilePath(paths.targetRoot);
|
||||
|
||||
const prompts = getPromptsForRole(template.role);
|
||||
|
||||
if (codeOwnersFilePath) {
|
||||
prompts.push(ownerPrompt());
|
||||
}
|
||||
|
||||
const deprecatedParams: PortableTemplateParams = {};
|
||||
if (config.isUsingDefaultTemplates && prefilledParams.id) {
|
||||
console.warn(
|
||||
`DEPRECATION WARNING: The 'id' parameter is deprecated, use 'pluginId' instead`,
|
||||
);
|
||||
deprecatedParams.pluginId = prefilledParams.id;
|
||||
}
|
||||
|
||||
const parameters = {
|
||||
...template.values,
|
||||
...prefilledParams,
|
||||
...deprecatedParams,
|
||||
};
|
||||
|
||||
const needsAnswer = [];
|
||||
const prefilledAnswers = {} as PortableTemplateParams;
|
||||
for (const prompt of prompts) {
|
||||
if (prompt.name && parameters[prompt.name] !== undefined) {
|
||||
prefilledAnswers[prompt.name] = parameters[prompt.name];
|
||||
} else {
|
||||
needsAnswer.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
const promptAnswers = await inquirer.prompt<PortableTemplateParams>(
|
||||
needsAnswer,
|
||||
);
|
||||
|
||||
const answers = {
|
||||
...prefilledAnswers,
|
||||
...promptAnswers,
|
||||
};
|
||||
|
||||
const roleParams = {
|
||||
role: template.role,
|
||||
name: answers.name,
|
||||
pluginId: answers.pluginId,
|
||||
moduleId: answers.moduleId,
|
||||
} as PortableTemplateInputRoleParams;
|
||||
|
||||
const packageParams = resolvePackageParams({
|
||||
roleParams,
|
||||
packagePrefix: config.packageNamePrefix,
|
||||
pluginInfix: config.packageNamePluginInfix,
|
||||
});
|
||||
|
||||
return {
|
||||
roleParams,
|
||||
owner: answers.owner as string | undefined,
|
||||
license: config.license,
|
||||
version: config.version,
|
||||
private: config.private,
|
||||
publishRegistry: config.publishRegistry,
|
||||
packageName: packageParams.packageName,
|
||||
packagePath: packageParams.packagePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function namePrompt(): DistinctQuestion {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Enter the name of the package, without scope [required]',
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'Please enter the name of the package';
|
||||
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
||||
return 'Package names must be lowercase and contain only letters, digits, and dashes.';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginIdPrompt(): DistinctQuestion {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'pluginId',
|
||||
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 moduleIdIdPrompt(): DistinctQuestion {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'moduleId',
|
||||
message: 'Enter the ID of the module [required]',
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'Please enter the ID of the module';
|
||||
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
||||
return 'Module IDs must be lowercase and contain only letters, digits, and dashes.';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPromptsForRole(
|
||||
role: PortableTemplateRole,
|
||||
): Array<DistinctQuestion> {
|
||||
switch (role) {
|
||||
case 'web-library':
|
||||
case 'node-library':
|
||||
case 'common-library':
|
||||
return [namePrompt()];
|
||||
case 'plugin-web-library':
|
||||
case 'plugin-node-library':
|
||||
case 'plugin-common-library':
|
||||
case 'frontend-plugin':
|
||||
case 'backend-plugin':
|
||||
return [pluginIdPrompt()];
|
||||
case 'frontend-plugin-module':
|
||||
case 'backend-plugin-module':
|
||||
return [pluginIdPrompt(), moduleIdIdPrompt()];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ownerPrompt(): DistinctQuestion {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'owner',
|
||||
message: 'Enter an owner to add to CODEOWNERS [optional]',
|
||||
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,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { collectPortableTemplateInput } from './collectPortableTemplateInput';
|
||||
export { loadPortableTemplateConfig } from './loadPortableTemplateConfig';
|
||||
export { selectTemplateInteractively } from './selectTemplateInteractively';
|
||||
export { loadPortableTemplate } from './loadPortableTemplate';
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { loadPortableTemplate } from './loadPortableTemplate';
|
||||
import { TEMPLATE_FILE_NAME } from '../types';
|
||||
|
||||
describe('loadTemplate', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
afterEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
|
||||
it('should load a valid template', async () => {
|
||||
mockDir.setContent({
|
||||
'path/to': {
|
||||
[TEMPLATE_FILE_NAME]: `
|
||||
name: template1
|
||||
role: frontend-plugin
|
||||
values:
|
||||
foo: bar
|
||||
`,
|
||||
},
|
||||
'path/to/hello.txt': 'hello world',
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplate({
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to', TEMPLATE_FILE_NAME),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: 'template1',
|
||||
role: 'frontend-plugin',
|
||||
files: [{ path: 'hello.txt', content: 'hello world' }],
|
||||
values: { foo: 'bar' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if template file does not exist', async () => {
|
||||
mockDir.setContent({});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplate({
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to/template1.yaml'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/^Failed to load template definition from '.*'; caused by Error: ENOENT/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if template definition is invalid', async () => {
|
||||
mockDir.setContent({
|
||||
'path/to/template1.yaml': `invalid: definition`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplate({
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to/template1.yaml'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Invalid template definition at '.*'; caused by Validation error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if target is a remote URL', async () => {
|
||||
await expect(
|
||||
loadPortableTemplate({
|
||||
name: 'template1',
|
||||
target: 'http://example.com',
|
||||
}),
|
||||
).rejects.toThrow('Remote templates are not supported yet');
|
||||
});
|
||||
|
||||
it('should throw an error if the package role is invalid', async () => {
|
||||
mockDir.setContent({
|
||||
'path/to/template1.yaml': `
|
||||
name: x
|
||||
role: invalid-role
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplate({
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to/template1.yaml'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Invalid template definition at '${mockDir.resolve(
|
||||
'path/to/template1.yaml',
|
||||
)}'; caused by Validation error: Invalid enum value`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 { z } from 'zod';
|
||||
import fs from 'fs-extra';
|
||||
import recursiveReaddir from 'recursive-readdir';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'path';
|
||||
import { dirname } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { paths } from '../../paths';
|
||||
import {
|
||||
PortableTemplateFile,
|
||||
PortableTemplatePointer,
|
||||
TEMPLATE_ROLES,
|
||||
} from '../types';
|
||||
import { PortableTemplate } from '../types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
|
||||
const templateDefinitionSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
role: z.enum(TEMPLATE_ROLES),
|
||||
description: z.string().optional(),
|
||||
values: z.record(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function loadPortableTemplate(
|
||||
pointer: PortableTemplatePointer,
|
||||
): Promise<PortableTemplate> {
|
||||
if (pointer.target.match(/https?:\/\//)) {
|
||||
throw new Error('Remote templates are not supported yet');
|
||||
}
|
||||
const templateContent = await fs
|
||||
.readFile(paths.resolveTargetRoot(pointer.target), 'utf-8')
|
||||
.catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Failed to load template definition from '${pointer.target}'`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
const rawTemplate = parseYaml(templateContent);
|
||||
|
||||
const parsed = templateDefinitionSchema.safeParse(rawTemplate);
|
||||
if (!parsed.success) {
|
||||
throw new ForwardedError(
|
||||
`Invalid template definition at '${pointer.target}'`,
|
||||
fromZodError(parsed.error),
|
||||
);
|
||||
}
|
||||
|
||||
const { role, values = {} } = parsed.data;
|
||||
|
||||
const templatePath = resolvePath(dirname(pointer.target));
|
||||
const filePaths = await recursiveReaddir(templatePath).catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Failed to load template contents from '${templatePath}'`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
const loadedFiles = new Array<PortableTemplateFile>();
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const path = relativePath(templatePath, filePath);
|
||||
if (filePath === pointer.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8').catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Failed to load file contents from '${path}'`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
if (path.endsWith('.hbs')) {
|
||||
loadedFiles.push({
|
||||
path: path.slice(0, -4),
|
||||
content,
|
||||
syntax: 'handlebars',
|
||||
});
|
||||
} else {
|
||||
loadedFiles.push({ path, content });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: pointer.name,
|
||||
role,
|
||||
files: loadedFiles,
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* 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 { realpathSync } from 'node:fs';
|
||||
import { loadPortableTemplateConfig } from './loadPortableTemplateConfig';
|
||||
import { defaultTemplates } from '../defaultTemplates';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { TEMPLATE_FILE_NAME } from '../types';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
describe('loadPortableTemplateConfig', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
afterEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
|
||||
it('should load configuration from package.json', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: ['./path/to/template1'],
|
||||
globals: {
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
namePrefix: '@acme/',
|
||||
namePluginInfix: 'backstage-plugin-',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'path/to/template1': {
|
||||
[TEMPLATE_FILE_NAME]: 'name: template1\nrole: frontend-plugin\n',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: false,
|
||||
templatePointers: [
|
||||
{
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to/template1', TEMPLATE_FILE_NAME),
|
||||
},
|
||||
],
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
version: '0.1.0',
|
||||
packageNamePrefix: '@acme/',
|
||||
packageNamePluginInfix: 'backstage-plugin-',
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
overrides: {
|
||||
license: 'nope',
|
||||
private: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: false,
|
||||
templatePointers: [
|
||||
{
|
||||
name: 'template1',
|
||||
target: mockDir.resolve('path/to/template1', TEMPLATE_FILE_NAME),
|
||||
},
|
||||
],
|
||||
license: 'nope',
|
||||
version: '0.1.0',
|
||||
private: false,
|
||||
packageNamePrefix: '@acme/',
|
||||
packageNamePluginInfix: 'backstage-plugin-',
|
||||
});
|
||||
});
|
||||
|
||||
it('should support pointing to built-in templates', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: ['@my/package/templates/plugin', 'my-package'],
|
||||
globals: {
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
namePrefix: '@acme/',
|
||||
namePluginInfix: 'backstage-plugin-',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
node_modules: {
|
||||
'@my': {
|
||||
package: {
|
||||
templates: {
|
||||
plugin: {
|
||||
[TEMPLATE_FILE_NAME]:
|
||||
'name: frontend-plugin\nrole: frontend-plugin\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'my-package': {
|
||||
[TEMPLATE_FILE_NAME]: 'name: backend-plugin\nrole: backend-plugin\n',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: false,
|
||||
templatePointers: [
|
||||
{
|
||||
name: 'frontend-plugin',
|
||||
target: realpathSync(
|
||||
mockDir.resolve(
|
||||
'node_modules/@my/package/templates/plugin',
|
||||
TEMPLATE_FILE_NAME,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'backend-plugin',
|
||||
target: realpathSync(
|
||||
mockDir.resolve('node_modules/my-package', TEMPLATE_FILE_NAME),
|
||||
),
|
||||
},
|
||||
],
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
version: '0.1.0',
|
||||
packageNamePrefix: '@acme/',
|
||||
packageNamePluginInfix: 'backstage-plugin-',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default templates if none are specified', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
globals: {
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
node_modules: Object.fromEntries(
|
||||
defaultTemplates.map(t => [
|
||||
t,
|
||||
{ [TEMPLATE_FILE_NAME]: `name: ${basename(t)}\nrole: web-library\n` },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: true,
|
||||
templatePointers: defaultTemplates.map(t => ({
|
||||
name: basename(t),
|
||||
target: realpathSync(
|
||||
mockDir.resolve(`node_modules/${t}`, TEMPLATE_FILE_NAME),
|
||||
),
|
||||
})),
|
||||
license: 'MIT',
|
||||
private: true,
|
||||
version: '0.1.0',
|
||||
packageNamePrefix: '@internal/',
|
||||
packageNamePluginInfix: 'plugin-',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject templates with conflicting names', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: ['./template1', './template2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
template1: {
|
||||
[TEMPLATE_FILE_NAME]: 'name: test\nrole: frontend-plugin\n',
|
||||
},
|
||||
template2: {
|
||||
[TEMPLATE_FILE_NAME]: 'name: test\nrole: backend-plugin\n',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Invalid template configuration, received conflicting template name 'test' from './template1' and './template2'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if package.json is invalid', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: 'invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/^Failed to load templating configuration from '.*'; caused by Validation error: Expected array/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if built-in template does not exist', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: ['./invalid'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Failed to load template definition '.\/invalid'; caused by Error: ENOENT`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if template point is absolute', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
backstage: {
|
||||
cli: {
|
||||
new: {
|
||||
templates: ['/invalid'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Failed to load template definition '/invalid'; caused by Error: Template target may not be an absolute path",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing backstage.new configuration', async () => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({}),
|
||||
node_modules: Object.fromEntries(
|
||||
defaultTemplates.map(t => [
|
||||
t,
|
||||
{ [TEMPLATE_FILE_NAME]: `name: ${basename(t)}\nrole: web-library\n` },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: true,
|
||||
templatePointers: expect.any(Array),
|
||||
license: 'Apache-2.0',
|
||||
version: '0.1.0',
|
||||
private: true,
|
||||
packageNamePrefix: '@internal/',
|
||||
packageNamePluginInfix: 'plugin-',
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadPortableTemplateConfig({
|
||||
packagePath: mockDir.resolve('package.json'),
|
||||
overrides: {
|
||||
license: 'nope',
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
isUsingDefaultTemplates: true,
|
||||
templatePointers: expect.any(Array),
|
||||
license: 'nope',
|
||||
version: '0.1.0',
|
||||
private: true,
|
||||
packageNamePrefix: '@internal/',
|
||||
packageNamePluginInfix: 'plugin-',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath, dirname, isAbsolute } from 'node:path';
|
||||
import { paths } from '../../paths';
|
||||
import { defaultTemplates } from '../defaultTemplates';
|
||||
import {
|
||||
PortableTemplateConfig,
|
||||
PortableTemplatePointer,
|
||||
TEMPLATE_FILE_NAME,
|
||||
} from '../types';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
const defaults = {
|
||||
license: 'Apache-2.0',
|
||||
version: '0.1.0',
|
||||
private: true,
|
||||
publishRegistry: undefined,
|
||||
packageNamePrefix: '@internal/',
|
||||
packageNamePluginInfix: 'plugin-',
|
||||
};
|
||||
|
||||
const newConfigSchema = z
|
||||
.object({
|
||||
templates: z.array(z.string()).optional(),
|
||||
globals: z
|
||||
.object({
|
||||
license: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
private: z.boolean().optional(),
|
||||
publishRegistry: z.string().optional(),
|
||||
namePrefix: z.string().optional(),
|
||||
namePluginInfix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const pkgJsonWithNewConfigSchema = z.object({
|
||||
backstage: z
|
||||
.object({
|
||||
cli: z
|
||||
.object({
|
||||
new: newConfigSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type LoadConfigOptions = {
|
||||
packagePath?: string;
|
||||
overrides?: Partial<PortableTemplateConfig>;
|
||||
};
|
||||
|
||||
export async function loadPortableTemplateConfig(
|
||||
options: LoadConfigOptions = {},
|
||||
): Promise<PortableTemplateConfig> {
|
||||
const { overrides = {} } = options;
|
||||
const pkgPath =
|
||||
options.packagePath ?? paths.resolveTargetRoot('package.json');
|
||||
const pkgJson = await fs.readJson(pkgPath);
|
||||
|
||||
const parsed = pkgJsonWithNewConfigSchema.safeParse(pkgJson);
|
||||
if (!parsed.success) {
|
||||
throw new ForwardedError(
|
||||
`Failed to load templating configuration from '${pkgPath}'`,
|
||||
fromZodError(parsed.error),
|
||||
);
|
||||
}
|
||||
|
||||
const config = parsed.data.backstage?.cli?.new;
|
||||
|
||||
const basePath = dirname(pkgPath);
|
||||
const templatePointerEntries = await Promise.all(
|
||||
(config?.templates ?? defaultTemplates).map(async rawPointer => {
|
||||
try {
|
||||
const templatePath = resolveLocalTemplatePath(rawPointer, basePath);
|
||||
|
||||
const pointer = await peekLocalTemplateDefinition(templatePath);
|
||||
return { pointer, rawPointer };
|
||||
} catch (error) {
|
||||
throw new ForwardedError(
|
||||
`Failed to load template definition '${rawPointer}'`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const templateNameConflicts = new Map<string, string>();
|
||||
for (const { pointer, rawPointer } of templatePointerEntries) {
|
||||
const conflict = templateNameConflicts.get(pointer.name);
|
||||
if (conflict) {
|
||||
throw new Error(
|
||||
`Invalid template configuration, received conflicting template name '${pointer.name}' from '${conflict}' and '${rawPointer}'`,
|
||||
);
|
||||
}
|
||||
templateNameConflicts.set(pointer.name, rawPointer);
|
||||
}
|
||||
|
||||
return {
|
||||
isUsingDefaultTemplates: !config?.templates,
|
||||
templatePointers: templatePointerEntries.map(({ pointer }) => pointer),
|
||||
license: overrides.license ?? config?.globals?.license ?? defaults.license,
|
||||
version: overrides.version ?? config?.globals?.version ?? defaults.version,
|
||||
private: overrides.private ?? config?.globals?.private ?? defaults.private,
|
||||
publishRegistry:
|
||||
overrides.publishRegistry ??
|
||||
config?.globals?.publishRegistry ??
|
||||
defaults.publishRegistry,
|
||||
packageNamePrefix:
|
||||
overrides.packageNamePrefix ??
|
||||
config?.globals?.namePrefix ??
|
||||
defaults.packageNamePrefix,
|
||||
packageNamePluginInfix:
|
||||
overrides.packageNamePluginInfix ??
|
||||
config?.globals?.namePluginInfix ??
|
||||
defaults.packageNamePluginInfix,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLocalTemplatePath(pointer: string, basePath: string): string {
|
||||
if (isAbsolute(pointer)) {
|
||||
throw new Error(`Template target may not be an absolute path`);
|
||||
}
|
||||
|
||||
if (pointer.startsWith('.')) {
|
||||
return resolvePath(basePath, pointer, TEMPLATE_FILE_NAME);
|
||||
}
|
||||
|
||||
return require.resolve(`${pointer}/${TEMPLATE_FILE_NAME}`, {
|
||||
paths: [basePath],
|
||||
});
|
||||
}
|
||||
|
||||
const partialTemplateDefinitionSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
async function peekLocalTemplateDefinition(
|
||||
target: string,
|
||||
): Promise<PortableTemplatePointer> {
|
||||
const content = await fs.readFile(target, 'utf8');
|
||||
|
||||
const rawTemplate = parseYaml(content);
|
||||
const parsed = partialTemplateDefinitionSchema.safeParse(rawTemplate);
|
||||
if (!parsed.success) {
|
||||
throw fromZodError(parsed.error);
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
target,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2024 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 { resolvePackageParams } from './resolvePackageParams';
|
||||
|
||||
describe.each([
|
||||
[
|
||||
{ role: 'web-library', name: 'test' },
|
||||
{
|
||||
packageName: '@internal/test',
|
||||
packagePath: 'packages/test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'node-library', name: 'test' },
|
||||
{
|
||||
packageName: '@internal/test',
|
||||
packagePath: 'packages/test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'common-library', name: 'test' },
|
||||
{
|
||||
packageName: '@internal/test',
|
||||
packagePath: 'packages/test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'plugin-web-library', pluginId: 'test' },
|
||||
{
|
||||
packageName: '@internal/plugin-test-react',
|
||||
packagePath: 'plugins/test-react',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'plugin-node-library', pluginId: 'test' },
|
||||
{
|
||||
packageName: '@internal/plugin-test-node',
|
||||
packagePath: 'plugins/test-node',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'plugin-common-library', pluginId: 'test' },
|
||||
{
|
||||
packageName: '@internal/plugin-test-common',
|
||||
packagePath: 'plugins/test-common',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'frontend-plugin', pluginId: 'test' },
|
||||
{
|
||||
packageName: '@internal/plugin-test',
|
||||
packagePath: 'plugins/test',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'backend-plugin', pluginId: 'test' },
|
||||
{
|
||||
packageName: '@internal/plugin-test-backend',
|
||||
packagePath: 'plugins/test-backend',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'frontend-plugin-module', pluginId: 'test1', moduleId: 'test2' },
|
||||
{
|
||||
packageName: '@internal/plugin-test1-module-test2',
|
||||
packagePath: 'plugins/test1-module-test2',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ role: 'backend-plugin-module', pluginId: 'test1', moduleId: 'test2' },
|
||||
{
|
||||
packageName: '@internal/plugin-test1-backend-module-test2',
|
||||
packagePath: 'plugins/test1-backend-module-test2',
|
||||
},
|
||||
],
|
||||
] as const)('resolvePackageInfo', (roleParams, packageInfo) => {
|
||||
it(`should generate correct info with default config for ${roleParams.role}`, () => {
|
||||
expect(
|
||||
resolvePackageParams({
|
||||
roleParams,
|
||||
packagePrefix: '@internal/',
|
||||
pluginInfix: 'plugin-',
|
||||
}),
|
||||
).toEqual(packageInfo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 { join as joinPath } from 'path';
|
||||
import { PortableTemplateInputRoleParams } from '../types';
|
||||
|
||||
export type ResolvePackageParamsOptions = {
|
||||
roleParams: PortableTemplateInputRoleParams;
|
||||
pluginInfix: string;
|
||||
packagePrefix: string;
|
||||
};
|
||||
|
||||
export type PortableTemplatePackageInfo = {
|
||||
packageName: string;
|
||||
packagePath: string;
|
||||
};
|
||||
|
||||
export function resolvePackageParams(
|
||||
options: ResolvePackageParamsOptions,
|
||||
): PortableTemplatePackageInfo {
|
||||
const baseName = getBaseNameForRole(options.roleParams);
|
||||
const isPlugin = options.roleParams.role.includes('plugin');
|
||||
const pluginInfix = isPlugin ? options.pluginInfix : '';
|
||||
return {
|
||||
packageName: `${options.packagePrefix}${pluginInfix}${baseName}`,
|
||||
packagePath: joinPath(isPlugin ? 'plugins' : 'packages', baseName),
|
||||
};
|
||||
}
|
||||
|
||||
function getBaseNameForRole(
|
||||
roleParams: PortableTemplateInputRoleParams,
|
||||
): string {
|
||||
switch (roleParams.role) {
|
||||
case 'web-library':
|
||||
case 'node-library':
|
||||
case 'common-library':
|
||||
return roleParams.name;
|
||||
case 'plugin-web-library':
|
||||
return `${roleParams.pluginId}-react`;
|
||||
case 'plugin-node-library':
|
||||
return `${roleParams.pluginId}-node`;
|
||||
case 'plugin-common-library':
|
||||
return `${roleParams.pluginId}-common`;
|
||||
case 'frontend-plugin':
|
||||
return `${roleParams.pluginId}`;
|
||||
case 'frontend-plugin-module':
|
||||
return `${roleParams.pluginId}-module-${roleParams.moduleId}`;
|
||||
case 'backend-plugin':
|
||||
return `${roleParams.pluginId}-backend`;
|
||||
case 'backend-plugin-module':
|
||||
return `${roleParams.pluginId}-backend-module-${roleParams.moduleId}`;
|
||||
default:
|
||||
throw new Error(`Unknown role ${(roleParams as { role: string }).role}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 { PortableTemplateConfig } from '../types';
|
||||
import inquirer from 'inquirer';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
import { selectTemplateInteractively } from './selectTemplateInteractively';
|
||||
|
||||
describe('selectTemplateInteractively', () => {
|
||||
const mockConfig = {
|
||||
isUsingDefaultTemplates: false,
|
||||
templatePointers: [
|
||||
{ name: 'template1', target: '/path/to/template1' },
|
||||
{ name: 'template2', target: '/path/to/template2' },
|
||||
],
|
||||
} as PortableTemplateConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should select a template interactively', async () => {
|
||||
jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ name: 'template1' });
|
||||
|
||||
const result = await selectTemplateInteractively(mockConfig);
|
||||
|
||||
expect(result).toEqual({ name: 'template1', target: '/path/to/template1' });
|
||||
});
|
||||
|
||||
it('should error if interactive selections is not found', async () => {
|
||||
jest
|
||||
.spyOn(inquirer, 'prompt')
|
||||
.mockResolvedValueOnce({ name: 'nonexistent' });
|
||||
|
||||
await expect(selectTemplateInteractively(mockConfig)).rejects.toThrow(
|
||||
"Template 'nonexistent' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('should use preselected template name', async () => {
|
||||
const result = await selectTemplateInteractively(mockConfig, 'template2');
|
||||
|
||||
expect(result).toEqual({ name: 'template2', target: '/path/to/template2' });
|
||||
});
|
||||
|
||||
it('should throw an error if template is not found', async () => {
|
||||
await expect(
|
||||
selectTemplateInteractively(mockConfig, 'nonexistent'),
|
||||
).rejects.toThrow("Template 'nonexistent' not found");
|
||||
});
|
||||
|
||||
it('should rewrite plugin to frontend-plugin if default templates are used', async () => {
|
||||
await expect(
|
||||
selectTemplateInteractively(mockConfig, 'plugin'),
|
||||
).rejects.toThrow("Template 'plugin' not found");
|
||||
|
||||
const logs = await withLogCollector(async () => {
|
||||
await expect(
|
||||
selectTemplateInteractively(
|
||||
{ ...mockConfig, isUsingDefaultTemplates: true },
|
||||
'plugin',
|
||||
),
|
||||
).rejects.toThrow("Template 'frontend-plugin' not found");
|
||||
});
|
||||
expect(logs).toEqual({
|
||||
log: [],
|
||||
warn: [
|
||||
"DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead",
|
||||
],
|
||||
error: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 inquirer from 'inquirer';
|
||||
import { PortableTemplateConfig, PortableTemplatePointer } from '../types';
|
||||
|
||||
export async function selectTemplateInteractively(
|
||||
config: PortableTemplateConfig,
|
||||
preselectedTemplateName?: string,
|
||||
): Promise<PortableTemplatePointer> {
|
||||
let selectedName = preselectedTemplateName;
|
||||
|
||||
if (config.isUsingDefaultTemplates && selectedName === 'plugin') {
|
||||
console.warn(
|
||||
`DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead`,
|
||||
);
|
||||
selectedName = 'frontend-plugin';
|
||||
}
|
||||
|
||||
if (!selectedName) {
|
||||
const answers = await inquirer.prompt<{ name: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'name',
|
||||
message: 'What do you want to create?',
|
||||
choices: config.templatePointers.map(t =>
|
||||
t.description
|
||||
? { name: `${t.name} - ${t.description}`, value: t.name }
|
||||
: t.name,
|
||||
),
|
||||
},
|
||||
]);
|
||||
selectedName = answers.name;
|
||||
}
|
||||
|
||||
const template = config.templatePointers.find(t => t.name === selectedName);
|
||||
if (!template) {
|
||||
throw new Error(`Template '${selectedName}' not found`);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
@@ -14,68 +14,104 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Answers, DistinctQuestion } from 'inquirer';
|
||||
export type PortableTemplateConfig = {
|
||||
/**
|
||||
* The pointers to templates that can be used.
|
||||
*/
|
||||
templatePointers: PortableTemplatePointer[];
|
||||
|
||||
/**
|
||||
* Whether the default set of templates are being used or not.
|
||||
*/
|
||||
isUsingDefaultTemplates: boolean;
|
||||
|
||||
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;
|
||||
/** License to use for new packages */
|
||||
license: string;
|
||||
|
||||
/** Creates a temporary directory. This will always be deleted after creation is done. */
|
||||
createTemporaryDirectory(name: string): Promise<string>;
|
||||
version: string;
|
||||
|
||||
/** Signal that the creation process got to a point where permanent modifications were made */
|
||||
markAsModified(): void;
|
||||
}
|
||||
private: boolean;
|
||||
|
||||
export type AnyOptions = Record<string, string>;
|
||||
publishRegistry?: string;
|
||||
|
||||
export type Prompt<TOptions extends Answers> = DistinctQuestion<TOptions> & {
|
||||
name: string;
|
||||
packageNamePrefix: string;
|
||||
|
||||
packageNamePluginInfix: string;
|
||||
};
|
||||
|
||||
export interface Factory<TOptions extends AnyOptions> {
|
||||
/**
|
||||
* The name used for this factory.
|
||||
*/
|
||||
export const TEMPLATE_FILE_NAME = 'portable-template.yaml';
|
||||
|
||||
export type PortableTemplatePointer = {
|
||||
name: string;
|
||||
description?: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A description that describes what this factory creates to the user.
|
||||
*/
|
||||
description: string;
|
||||
export const TEMPLATE_ROLES = [
|
||||
'web-library',
|
||||
'node-library',
|
||||
'common-library',
|
||||
'plugin-web-library',
|
||||
'plugin-node-library',
|
||||
'plugin-common-library',
|
||||
'frontend-plugin',
|
||||
'frontend-plugin-module',
|
||||
'backend-plugin',
|
||||
'backend-plugin-module',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* An optional options discovery step that is run
|
||||
* before the prompts to potentially fill in some of the options.
|
||||
*/
|
||||
optionsDiscovery?(): Promise<Partial<TOptions>>;
|
||||
export type PortableTemplateRole = (typeof TEMPLATE_ROLES)[number];
|
||||
|
||||
/**
|
||||
* Inquirer prompts that will be filled in either interactively or
|
||||
* through command line arguments.
|
||||
*/
|
||||
optionsPrompts?: ReadonlyArray<Prompt<TOptions>>;
|
||||
export type PortableTemplateFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
syntax?: 'handlebars';
|
||||
};
|
||||
|
||||
/**
|
||||
* The main method of the factory that handles creation.
|
||||
*/
|
||||
create(options: TOptions, context?: CreateContext): Promise<void>;
|
||||
}
|
||||
export type PortableTemplate = {
|
||||
name: string;
|
||||
role: PortableTemplateRole;
|
||||
files: PortableTemplateFile[];
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AnyFactory = Factory<AnyOptions>;
|
||||
export type PortableTemplateParams = {
|
||||
[KName in string]?: string | number | boolean;
|
||||
};
|
||||
|
||||
export function createFactory<TOptions extends AnyOptions>(
|
||||
config: Factory<TOptions>,
|
||||
): AnyFactory {
|
||||
return config as AnyFactory;
|
||||
}
|
||||
export type PortableTemplateInputRoleParams =
|
||||
| {
|
||||
role: 'web-library' | 'node-library' | 'common-library';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
role:
|
||||
| 'plugin-web-library'
|
||||
| 'plugin-node-library'
|
||||
| 'plugin-common-library'
|
||||
| 'frontend-plugin'
|
||||
| 'backend-plugin';
|
||||
pluginId: string;
|
||||
}
|
||||
| {
|
||||
role: 'frontend-plugin-module' | 'backend-plugin-module';
|
||||
pluginId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
export type PortableTemplateInput = {
|
||||
roleParams: PortableTemplateInputRoleParams;
|
||||
|
||||
owner?: string;
|
||||
|
||||
license: string;
|
||||
|
||||
version: string;
|
||||
|
||||
private: boolean;
|
||||
|
||||
publishRegistry?: string;
|
||||
|
||||
packageName: string;
|
||||
|
||||
packagePath: string;
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { templatingTask } from './tasks';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('templatingTask', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
it('should template a directory with mix of regular files and templates', async () => {
|
||||
// Testing template directory
|
||||
const tmplDir = 'test-tmpl';
|
||||
|
||||
// Temporary dest dir to write the template to
|
||||
const destDir = 'test-dest';
|
||||
|
||||
// Files content
|
||||
const testFileContent = 'testing';
|
||||
const testVersionFileContent =
|
||||
"version: {{pluginVersion}} {{versionQuery 'mock-pkg'}}";
|
||||
|
||||
mockDir.setContent({
|
||||
[tmplDir]: {
|
||||
sub: {
|
||||
'version.txt.hbs': testVersionFileContent,
|
||||
},
|
||||
'test.txt': testFileContent,
|
||||
},
|
||||
[destDir]: {},
|
||||
});
|
||||
|
||||
await templatingTask(
|
||||
mockDir.resolve(tmplDir),
|
||||
mockDir.resolve(destDir),
|
||||
{
|
||||
pluginVersion: '0.0.0',
|
||||
},
|
||||
() => '^0.1.2',
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve(destDir, 'test.txt'), 'utf8'),
|
||||
).resolves.toBe(testFileContent);
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve(destDir, 'sub/version.txt'), 'utf8'),
|
||||
).resolves.toBe('version: 0.0.0 ^0.1.2');
|
||||
});
|
||||
});
|
||||
@@ -15,15 +15,10 @@
|
||||
*/
|
||||
|
||||
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 { assertError } from '@backstage/errors';
|
||||
import { paths } from './paths';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
@@ -96,138 +91,3 @@ export class Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function templatingTask(
|
||||
templateDir: string,
|
||||
destinationDir: string,
|
||||
context: any,
|
||||
versionProvider: (name: string, versionHint?: string) => string,
|
||||
isMonoRepo: boolean,
|
||||
) {
|
||||
const files = await recursive(templateDir).catch(error => {
|
||||
throw new Error(`Failed to read template directory: ${error.message}`);
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
const destinationFile = file.replace(templateDir, destinationDir);
|
||||
await fs.ensureDir(dirname(destinationFile));
|
||||
|
||||
if (file.endsWith('.hbs')) {
|
||||
await Task.forItem('templating', basename(file), async () => {
|
||||
const destination = destinationFile.replace(/\.hbs$/, '');
|
||||
|
||||
const template = await fs.readFile(file);
|
||||
const compiled = handlebars.compile(template.toString(), {
|
||||
strict: true,
|
||||
});
|
||||
const contents = compiled(
|
||||
{ name: basename(destination), ...context },
|
||||
{
|
||||
helpers: {
|
||||
versionQuery(name: string, versionHint: string | unknown) {
|
||||
return versionProvider(
|
||||
name,
|
||||
typeof versionHint === 'string' ? versionHint : undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await fs.writeFile(destination, contents).catch(error => {
|
||||
throw new Error(
|
||||
`Failed to create file: ${destination}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (isMonoRepo && file.match('tsconfig.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Task.forItem('copying', basename(file), async () => {
|
||||
await fs.copyFile(file, destinationFile).catch(error => {
|
||||
const destination = destinationFile;
|
||||
throw new Error(
|
||||
`Failed to copy file to ${destination} : ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addToBackend(
|
||||
name: string,
|
||||
options: {
|
||||
type: 'plugin' | 'module';
|
||||
},
|
||||
) {
|
||||
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
|
||||
await Task.forItem('backend', `adding ${options.type}`, async () => {
|
||||
const backendFilePath = paths.resolveTargetRoot(
|
||||
'packages/backend/src/index.ts',
|
||||
);
|
||||
if (!(await fs.pathExists(backendFilePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(backendFilePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const backendAddLine = `backend.add(import('${name}'));`;
|
||||
|
||||
const backendStartIndex = lines.findIndex(line =>
|
||||
line.match(/backend.start/),
|
||||
);
|
||||
|
||||
if (backendStartIndex !== -1) {
|
||||
const [indentation] = lines[backendStartIndex].match(/^\s*/)!;
|
||||
lines.splice(backendStartIndex, 0, `${indentation}${backendAddLine}`);
|
||||
|
||||
const newContent = lines.join('\n');
|
||||
await fs.writeFile(backendFilePath, newContent, 'utf8');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# {{name}}
|
||||
# {{packageName}}
|
||||
|
||||
The {{moduleId}} backend module for the {{pluginId}} plugin.
|
||||
|
||||
+1
-9
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"name": "{{packageName}}",
|
||||
"description": "The {{moduleId}} backend module for the {{pluginId}} plugin.",
|
||||
"version": "{{packageVersion}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "{{license}}",
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
@@ -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 ) }}'
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# {{id}}
|
||||
# {{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 `{{name}}` package. To install it to your backend package, run the following command:
|
||||
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 {{name}}
|
||||
yarn --cwd packages/backend add {{packageName}}
|
||||
```
|
||||
|
||||
Then add the plugin to your backend in `packages/backend/src/index.ts`:
|
||||
@@ -16,7 +16,7 @@ Then add the plugin to your backend in `packages/backend/src/index.ts`:
|
||||
```ts
|
||||
const backend = createBackend();
|
||||
// ...
|
||||
backend.add(import('{{name}}'));
|
||||
backend.add(import('{{packageName}}'));
|
||||
```
|
||||
|
||||
## Development
|
||||
+5
-5
@@ -11,17 +11,17 @@ import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
|
||||
//
|
||||
// Create a new todo item, standalone or for the sample component:
|
||||
//
|
||||
// curl http://localhost:7007/api/{{id}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo"}'
|
||||
// curl http://localhost:7007/api/{{id}}/todos -H 'Content-Type: application/json' -d '{"title": "My Todo", "entityRef": "component:default/sample"}'
|
||||
// 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/{{id}}/todos
|
||||
// curl http://localhost:7007/api/{{pluginId}}/todos
|
||||
//
|
||||
// Explicitly make an unauthenticated request, or with service auth:
|
||||
//
|
||||
// curl http://localhost:7007/api/{{id}}/todos -H 'Authorization: Bearer mock-none-token'
|
||||
// curl http://localhost:7007/api/{{id}}/todos -H 'Authorization: Bearer mock-service-token'
|
||||
// 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();
|
||||
|
||||
+1
-9
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"version": "{{pluginVersion}}",
|
||||
"name": "{{packageName}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "{{license}}",
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
@@ -0,0 +1,5 @@
|
||||
name: backend-plugin
|
||||
role: backend-plugin
|
||||
description: A new backend plugin
|
||||
values:
|
||||
pluginVar: '{{ camelCase pluginId }}Plugin'
|
||||
+5
-5
@@ -17,12 +17,12 @@ describe('plugin', () => {
|
||||
features: [{{pluginVar}}],
|
||||
});
|
||||
|
||||
await request(server).get('/api/{{id}}/todos').expect(200, {
|
||||
await request(server).get('/api/{{pluginId}}/todos').expect(200, {
|
||||
items: [],
|
||||
});
|
||||
|
||||
const createRes = await request(server)
|
||||
.post('/api/{{id}}/todos')
|
||||
.post('/api/{{pluginId}}/todos')
|
||||
.send({ title: 'My Todo' });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
@@ -36,13 +36,13 @@ describe('plugin', () => {
|
||||
const createdTodoItem = createRes.body;
|
||||
|
||||
await request(server)
|
||||
.get('/api/{{id}}/todos')
|
||||
.get('/api/{{pluginId}}/todos')
|
||||
.expect(200, {
|
||||
items: [createdTodoItem],
|
||||
});
|
||||
|
||||
await request(server)
|
||||
.get(`/api/{{id}}/todos/${createdTodoItem.id}`)
|
||||
.get(`/api/{{pluginId}}/todos/${createdTodoItem.id}`)
|
||||
.expect(200, createdTodoItem);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('plugin', () => {
|
||||
});
|
||||
|
||||
const createRes = await request(server)
|
||||
.post('/api/{{id}}/todos')
|
||||
.post('/api/{{pluginId}}/todos')
|
||||
.send({ title: 'My Todo', entityRef: 'component:default/my-component' });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { createTodoListService } from './services/TodoListService';
|
||||
* @public
|
||||
*/
|
||||
export const {{pluginVar}} = createBackendPlugin({
|
||||
pluginId: '{{id}}',
|
||||
pluginId: '{{pluginId}}',
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": [
|
||||
"src",
|
||||
"dev",
|
||||
"migrations"
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# {{name}}
|
||||
|
||||
Welcome to the common package for the {{id}} plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# {{name}}
|
||||
|
||||
Welcome to the Node.js library package for the {{id}} plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": [
|
||||
"src",
|
||||
"dev"
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# {{name}}
|
||||
|
||||
Welcome to the web library package for the {{id}} plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
# {{id}}
|
||||
# {{pluginId}}
|
||||
|
||||
Welcome to the {{id}} plugin!
|
||||
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 [/{{id}}](http://localhost:3000/{{id}}).
|
||||
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.
|
||||
+1
-1
@@ -7,6 +7,6 @@ createDevApp()
|
||||
.addPage({
|
||||
element: <{{ extensionName }} />,
|
||||
title: 'Root Page',
|
||||
path: '/{{ id }}',
|
||||
path: '/{{pluginId}}',
|
||||
})
|
||||
.render();
|
||||
+1
-9
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"version": "{{pluginVersion}}",
|
||||
"name": "{{packageName}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "{{license}}",
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
@@ -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'
|
||||
+1
-1
@@ -23,7 +23,7 @@ describe('ExampleComponent', () => {
|
||||
it('should render', async () => {
|
||||
await renderInTestApp(<ExampleComponent />);
|
||||
expect(
|
||||
screen.getByText('Welcome to {{ id }}!'),
|
||||
screen.getByText('Welcome to {{pluginId}}!'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user