Merge branch 'backstage:master' into topic/add-entity-pull-requests

This commit is contained in:
Andre Wanlin
2021-11-17 06:36:49 -06:00
committed by GitHub
214 changed files with 6751 additions and 556 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is
duplicated in the tabs above.
+14
View File
@@ -0,0 +1,14 @@
---
'@backstage/config-loader': minor
---
Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`.
This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users.
If you use `loadConfig` directly you will need to update your usage from:
```diff
- const appConfigs = await loadConfig(options)
+ const { appConfigs } = await loadConfig(options)
```
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Fixed generation of sample data in the example Cost Insights client
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/catalog-model': patch
'@backstage/cli': patch
'@backstage/config-loader': patch
'@backstage/plugin-scaffolder': patch
---
Update the json-schema dependency version.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kafka-backend': patch
---
Update Kafka configuration types
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-common': patch
'@backstage/cli': patch
---
Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': patch
'@backstage/plugin-user-settings': patch
---
Add Props Icon for Sidebar Item SidebarSearchField and Settings
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Refactor and add regression tests for create-app tasks
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Paths can be specified in backend.reading.allow to further restrict allowed targets
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/test-utils': patch
---
Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': patch
'@backstage/plugin-auth-backend': patch
---
Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Skip empty file names when scaffolding with nunjucks
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Adjust entity query construction to ensure sub-queries are always isolated from one another.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Add `AbortSignal` support to `UrlReader`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Deprecated the `BackstagePluginWithAnyOutput` type.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Start exporting and marking several types as public to address errors in the API report.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added a scaffolder backend module template for the `create` command.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
Remove exports of unused types(`RouteOptions` and `RoutePath`).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Fixed entity triplet case handling for certain locales.
@@ -0,0 +1,5 @@
---
'@backstage/techdocs-common': patch
---
Added OpenStack Swift case migration support.
@@ -0,0 +1,6 @@
---
'@backstage/techdocs-common': patch
'@backstage/plugin-techdocs-backend': patch
---
Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Use a better checkbox rendering in a task list.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
adds getDefaultProcessor method to CatalogBuilder
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types.
+2
View File
@@ -18,7 +18,9 @@
/plugins/techdocs-backend @backstage/techdocs-core
/plugins/ilert @backstage/reviewers @yacut
/plugins/home @backstage/techdocs-core
/packages/embedded-techdocs-app @backstage/techdocs-core
/packages/search-common @backstage/techdocs-core
/packages/techdocs-cli @backstage/techdocs-core
/packages/techdocs-common @backstage/techdocs-core
/.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining
/.changeset/search-* @backstage/techdocs-core
+1
View File
@@ -63,6 +63,7 @@ Debounce
declaratively
deduplicated
deps
dependabot
destructured
dev
devops
+42
View File
@@ -0,0 +1,42 @@
name: Techdocs E2E Test
on:
pull_request:
paths-ignore:
- '.changeset/**'
- 'contrib/**'
- 'docs/**'
- 'microsite/**'
jobs:
verify:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x]
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- name: install dependencies
run: yarn install --frozen-lockfile
- name: generate types
run: yarn tsc
- name: build techdocs-cli
working-directory: packages/techdocs-cli
run: yarn build
- name: Install mkdocs & techdocs-core
run: python -m pip install mkdocs-techdocs-core
- name: techdocs-cli e2e test
working-directory: packages/techdocs-cli
run: yarn test:e2e:ci
+4 -1
View File
@@ -64,4 +64,7 @@
| [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc. |
| [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. |
| [Palo Alto Networks](https://www.paloaltonetworks.com) | [Jeremy Guarini](https://github.com/jeremyguarini), [Brian Lomeland](https://github.com/bbbmmmlll), [Palo Alto Networks](https://github.com/PaloAltoNetworks) | Developer portal, service catalog, documentation and tooling |
| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem
| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem |
| [Tradeshift](https://www.tradeshift.com/) | [Soren Mathiasen](https://github.com/sorenmat) | Developer Portal: documentation, monitoring, service templates, service catalog for our micro services |
| [Unity](https://unity.com) | [Ted Cordery](https://github.com/TeddyBallGame) | A centralized service catalog with documentation for our service engineers. |
| [PicPay](https://www.picpay.com) | [Luis Baroni](https://github.com/lcsbaroni), [Renata Poluceno](https://github.com/renatapoluceno), [PicPay](https://github.com/picpay) | Developer portal for building services throught templates, service catalog with ownership of services, documentation and metrics providing autonomy and visibility for all. |
+1 -1
View File
@@ -117,7 +117,7 @@ through the proxy.
Learn more about [the different components](overview/what-is-backstage.md) that
make up Backstage.
### Why can't I dynamically install plugins without modifications the app?
### Why can't I dynamically install plugins without modifications to the app?
This decision is part of the core architecture and development flow of
Backstage. Plugins have a lot of freedom in what they provide and how they are
+1 -1
View File
@@ -199,7 +199,7 @@ export a class that `implements` the target API, for example:
```ts
export class IgnoringErrorApi implements ErrorApi {
post(error: Error, context?: ErrorContext) {
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
// ignore error
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

@@ -144,7 +144,8 @@ spec:
Note that to be able to read from targets that are outside of the normal
integration points such as `github.com`, you'll need to explicitly allow it by
adding an entry in the `backend.reading.allow` list. For example:
adding an entry in the `backend.reading.allow` list. Paths can be specified to
further restrict targets For example:
```yml
backend:
@@ -153,6 +154,8 @@ backend:
allow:
- host: example.com
- host: '*.examples.org'
- host: example.net
paths: ['/api/']
```
## Common to All Kinds: The Envelope
+241
View File
@@ -0,0 +1,241 @@
# TechDocs CLI
Utility command line interface for managing TechDocs sites in
[Backstage](https://github.com/backstage/backstage).
https://backstage.io/docs/features/techdocs/techdocs-overview
## Features
- Supports local development/preview of a TechDocs site in a Backstage app.
- Supports generation and publishing of a documentation site in a CI/CD
workflow.
```bash
techdocs-cli --help
Usage: techdocs-cli [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
generate|build [options] Generate TechDocs documentation site using mkdocs.
publish [options] Publish generated TechDocs site to an external storage AWS S3,
Google GCS, etc.
serve:mkdocs [options] Serve a documentation project locally using mkdocs serve.
serve [options] Serve a documentation project locally in a Backstage app-like
environment
help [command] display help for command
```
## Installation
You can always use [`npx`](https://github.com/npm/npx) to run the latest version
of `techdocs-cli` -
```bash
npx @techdocs/cli [command]
```
Or you can install it using [npm](https://www.npmjs.com/package/@techdocs/cli) -
```bash
npm install -g @techdocs/cli
techdocs-cli [command]
```
## Usage
### Preview TechDocs site locally in a Backstage like environment
```bash
techdocs-cli serve
```
![A preview of techdocs-cli serve command](../../assets/features/techdocs/techdocs-cli-serve-preview.png)
By default, Docker and
[techdocs-container](https://github.com/backstage/techdocs-container) is used to
make sure all the dependencies are installed. However, Docker can be disabled
with `--no-docker` flag.
The command starts two local servers - an MkDocs preview server on port 8000 and
a Backstage app server on port 3000. The Backstage app has a custom TechDocs API
implementation, which uses the MkDocs preview server as a proxy to fetch the
generated documentation files and assets.
NOTE: When using a custom `techdocs` docker image, make sure the entry point is
also `ENTRYPOINT ["mkdocs"]`.
Command reference:
```bash
Usage: techdocs-cli serve [options]
Serve a documentation project locally in a Backstage app-like environment
Options:
-i, --docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs")
--no-docker Do not use Docker, use MkDocs executable in current user environment.
--mkdocs-port <PORT> Port for MkDocs server to use (default: "8000")
-v --verbose Enable verbose output. (default: false)
-h, --help display help for command
```
### Generate TechDocs site from a documentation project
```bash
techdocs-cli generate
```
Alias: `techdocs-cli build`
The generate command uses the
[`@backstage/techdocs-common`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common)
package from Backstage for consistency. A Backstage app can also generate and
publish TechDocs sites if `techdocs.builder` is set to `'local'` in
`app-config.yaml`. See
[configuration reference](https://backstage.io/docs/features/techdocs/configuration).
By default, this command uses Docker and
[techdocs-container](https://github.com/backstage/techdocs-container) to make
sure all the dependencies are installed. But it can be disabled using
`--no-docker` flag.
Command reference:
```bash
techdocs-cli generate --help
Usage: techdocs-cli generate|build [options]
Generate TechDocs documentation site using MkDocs.
Options:
--source-dir <PATH> Source directory containing mkdocs.yml and docs/ directory. (default: ".")
--output-dir <PATH> Output directory containing generated TechDocs site. (default: "./site/")
--docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs:v0.3.4")
--no-pull Do not pull the latest docker image
--no-docker Do not use Docker, use MkDocs executable and plugins in current user environment.
--techdocs-ref <HOST_TYPE:URL> The repository hosting documentation source files e.g.
github:https://ghe.mycompany.net.com/org/repo.
This value is same as the backstage.io/techdocs-ref annotation of the corresponding
Backstage entity.
It is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml
if not found.
--etag <ETAG> A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored
in techdocs_metadata.json.
-v --verbose Enable verbose output. (default: false)
-h, --help display help for command
```
### Publish generated TechDocs sites
```bash
techdocs-cli publish --publisher-type <awsS3|googleGcs|azureBlobStorage> --storage-name <bucket/container name> --entity <namespace/kind/name>
```
After generating a TechDocs site using `techdocs-cli generate`, use the publish
command to upload the static generated files on a cloud storage (AWS/GCS) bucket
or (Azure) container which your Backstage app can read from.
The value for `--entity` must be the Backstage entity which the generated
TechDocs site belongs to. You can find the values in your Entity's
`catalog-info.yaml` file. If namespace is missing in the `catalog-info.yaml`,
use `default`. The directory structure used in the storage bucket is
`namespace/kind/name/<files>`.
Note that the values are case-sensitive. An example for `--entity` is
`default/Component/<entityName>`.
Command reference:
```bash
Usage: techdocs-cli publish [options]
Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.
Options:
--publisher-type <TYPE> (Required always) awsS3 | googleGcs | azureBlobStorage
- same as techdocs.publisher.type in Backstage
app-config.yaml
--storage-name <BUCKET/CONTAINER NAME> (Required always) In case of AWS/GCS, use the bucket
name. In case of Azure, use container name. Same as
techdocs.publisher.[TYPE].bucketName
--entity <NAMESPACE/KIND/NAME> (Required always) Entity uid separated by / in
namespace/kind/name order (case-sensitive). Example:
default/Component/myEntity
--legacyUseCaseSensitiveTripletPaths Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name).
Only use if your TechDocs backend is configured the same way
--azureAccountName <AZURE ACCOUNT NAME> (Required for Azure) specify when --publisher-type
azureBlobStorage
--azureAccountKey <AZURE ACCOUNT KEY> Azure Storage Account key to use for authentication.
If not specified, you must set AZURE_TENANT_ID,
AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment
variables.
--awsRoleArn <AWS ROLE ARN> Optional AWS ARN of role to be assumed.
--awsEndpoint <AWS ENDPOINT> Optional AWS endpoint to send requests to.
--awsS3ForcePathStyle Optional AWS S3 option to force path style.
--directory <PATH> Path of the directory containing generated files to
publish (default: "./site/")
-h, --help display help for command
```
### Migrate content for case-insensitive access
Prior to the beta version of TechDocs (`v[0.11.0]`), TechDocs were stored in
object storage using a case-sensitive entity triplet (e.g.
`default/API/name/index.html`). This resulted in a limitation where that exact
case was required in the Backstage URL in order to read/render TechDocs content.
As of `v[0.11.0]` of the TechDocs plugin, any case is allowed in the URL (e.g.
`default/api/name`), matching the behavior of the Catalog plugin.
Backstage instances created with TechDocs `v[0.11.0]` or later do not need this
command. However, when upgrading to this version from an older version of
TechDocs, the `migrate` command can be used prior to deployment to ensure docs
remain accessible without having to rebuild all docs.
Prior to upgrading to `v[0.11.0]` or greater, run this command to copy all
assets to their lower-case triplet equivalents like this:
```bash
techdocs-cli migrate --publisher-type <awsS3|googleGcs|azureBlobStorage> --storage-name <bucket/container name> --verbose
```
Once migrated and the upgraded version of the Backstage plugin has been
deployed, you can clean up the legacy, case-sensitive triplet files by
re-running the command with the `--removeOriginal` flag passed, which _moves_
(rather than copies) the files. Note: this deletes files and is therefore a
destructive operation that should performed with caution.
```bash
techdocs-cli migrate --publisher-type <awsS3|googleGcs|azureBlobStorage> --storage-name <bucket/container name> --removeOriginal --verbose
```
Afterward, update your TechDocs CLI to `v[0.7.0]` to ensure further publishing
happens using a lower-case entity triplet.
Note: arguments for this command largely match those of the `publish` command,
depending on your chosen storage provider. Run `techdocs-cli migrate --help` for
details.
#### Authentication
You need to make sure that your environment is able to authenticate with the
target cloud provider. `techdocs-cli` uses the official Node.js clients provided
by AWS (v2), Google Cloud and Azure. You can authenticate using environment
variables and/or by other means (`~/.aws/credentials`, `~/.config/gcloud` etc.)
Refer to the Authentication section of the following documentation depending
upon your cloud storage provider -
- [Google Cloud Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-google-gcs-bucket-with-techdocs)
- [AWS S3](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs)
- [Azure Blob Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-azure-blob-storage-container-with-techdocs)
## Development
You are welcome to contribute to TechDocs CLI to improve it and support new
features! See the project
[README](https://github.com/backstage/backstage/blob/main/src/packages/techdocs-cli/README.md)
for more information.
+6
View File
@@ -106,6 +106,12 @@ techdocs:
# This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs.
s3ForcePathStyle: false
# (Optional) AWS Server Side Encryption
# Defaults to undefined.
# If not set, encrypted buckets will fail to publish.
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html
sse: 'aws:kms' # or AES256
# Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise.
azureBlobStorage:
+39
View File
@@ -41,6 +41,7 @@ lint Lint a package
test Run tests, forwarding args to Jest, defaulting to watch mode
clean Delete cache directories
create Open up an interactive guide to creating new things in your app
create-plugin Creates a new plugin in the current repository
remove-plugin Removes plugin in the current repository
@@ -277,6 +278,44 @@ Options:
-h, --help display help for command
```
## create
Scope: `root`
The `create` command opens up an interactive guide for you to create new things
in your app. If you do not pass in any options it is completely interactive, but
it is possible to pre-select what you want to create using the `--select` flag,
and provide options using `--options`, for example:
```bash
backstage-cli create --select plugin --option id=foo
```
This command is typically added as script in the root `package.json` to be
executed with `yarn backstage-create`, using options that are appropriate for
the organization that owns the app repo. For example you may have it set up like
this:
```json
{
"scripts": {
"backstage-create": "backstage-cli create --scope internal --no-private --npm-registry https://acme.org/npm"
}
}
```
```text
Usage: backstage-cli create [options]
Options:
--select <name> Select the thing you want to be creating upfront
--option <name>=<value> Pre-fill options for the creation process (default: [])
--scope <scope> The scope to use for new packages
--npm-registry <URL> The package registry to use for new packages
--no-private Do not mark new packages as private
-h, --help display help for command
```
## create-plugin
Scope: `root`
+1
View File
@@ -105,6 +105,7 @@
"features/techdocs/configuration",
"features/techdocs/using-cloud-storage",
"features/techdocs/configuring-ci-cd",
"features/techdocs/cli",
"features/techdocs/how-to-guides",
"features/techdocs/troubleshooting",
"features/techdocs/faqs"
+1 -1
View File
@@ -61,7 +61,6 @@ nav:
- Writing Custom Actions: 'features/software-templates/writing-custom-actions.md'
- Writing Templates (Legacy): 'features/software-templates/legacy.md'
- Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md'
- Backstage Search:
- Overview: 'features/search/README.md'
- Getting Started: 'features/search/getting-started.md'
@@ -78,6 +77,7 @@ nav:
- TechDocs Configuration Options: 'features/techdocs/configuration.md'
- Using Cloud Storage: 'features/techdocs/using-cloud-storage.md'
- Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md'
- CLI: 'features/techdocs/cli.md'
- HOW TO guides: 'features/techdocs/how-to-guides.md'
- Troubleshooting: 'features/techdocs/troubleshooting.md'
- FAQ: 'features/techdocs/FAQ.md'
+5 -1
View File
@@ -23,13 +23,16 @@
"lint:all": "lerna run lint --",
"lint:type-deps": "node scripts/check-type-dependencies.js",
"docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image",
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
"backstage-create": "backstage-cli create --scope backstage --no-private",
"create-plugin": "yarn backstage-create --select plugin",
"remove-plugin": "backstage-cli remove-plugin",
"release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install",
"prettier:check": "prettier --check .",
"lerna": "lerna",
"storybook": "yarn workspace storybook start",
"build-storybook": "yarn workspace storybook build-storybook",
"techdocs-cli": "node scripts/techdocs-cli.js",
"techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js",
"prepare": "husky install",
"lock:check": "yarn-lock-check"
},
@@ -61,6 +64,7 @@
"@spotify/prettier-config": "^11.0.0",
"@types/webpack": "^5.28.0",
"command-exists": "^1.2.9",
"cross-env": "^7.0.0",
"concurrently": "^6.0.0",
"eslint-plugin-notice": "^0.9.10",
"fs-extra": "9.1.0",
+7 -3
View File
@@ -6,6 +6,7 @@
/// <reference types="node" />
/// <reference types="webpack-env" />
import { AbortSignal as AbortSignal_2 } from 'node-abort-controller';
import { AwsS3Integration } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
@@ -48,7 +49,7 @@ export class AwsS3UrlReader implements UrlReader {
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string): Promise<ReadTreeResponse>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
@@ -72,7 +73,7 @@ export class AzureUrlReader implements UrlReader {
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, _options?: ReadUrlOptions): Promise<ReadUrlResponse>;
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
@@ -94,7 +95,7 @@ export class BitbucketUrlReader implements UrlReader {
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, _options?: ReadUrlOptions): Promise<ReadUrlResponse>;
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
@@ -418,6 +419,7 @@ export type ReadTreeOptions = {
},
): boolean;
etag?: string;
signal?: AbortSignal_2;
};
// @public
@@ -471,6 +473,7 @@ export type ReadTreeResponseFile = {
// @public
export type ReadUrlOptions = {
etag?: string;
signal?: AbortSignal_2;
};
// @public
@@ -508,6 +511,7 @@ export type RunContainerOptions = {
// @public
export type SearchOptions = {
etag?: string;
signal?: AbortSignal_2;
};
// @public
+8
View File
@@ -130,6 +130,14 @@ export interface Config {
* The host may also contain a port, for example `example.com:8080`.
*/
host: string;
/**
* An optional list of paths. In case they are present only targets matching
* any of them will are allowed. You can use trailing slashes to make sure only
* subdirectories are allowed, for example `/mydir/` will allow targets with
* paths like `/mydir/a` but will block paths like `/mydir2`.
*/
paths?: string[];
}>;
};
+1
View File
@@ -62,6 +62,7 @@
"minimatch": "^3.0.4",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
"node-abort-controller": "^3.0.1",
"raw-body": "^2.4.1",
"selfsigned": "^1.10.7",
"stoppable": "^1.1.0",
+7 -5
View File
@@ -200,7 +200,7 @@ export async function loadBackendConfig(options: {
});
const config = new ObservableConfigProxy(options.logger);
const configs = await loadConfig({
const { appConfigs } = await loadConfig({
configRoot: paths.targetRoot,
configPaths: [],
configTargets: configTargets,
@@ -227,14 +227,16 @@ export async function loadBackendConfig(options: {
});
options.logger.info(
`Loaded config from ${configs.map(c => c.context).join(', ')}`,
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
);
config.setConfig(ConfigReader.fromConfigs(configs));
config.setConfig(ConfigReader.fromConfigs(appConfigs));
// Subscribe to config changes and update the redaction list for logging
updateRedactionList(schema, configs, options.logger);
config.subscribe(() => updateRedactionList(schema, configs, options.logger));
updateRedactionList(schema, appConfigs, options.logger);
config.subscribe(() =>
updateRedactionList(schema, appConfigs, options.logger),
);
return config;
}
@@ -48,6 +48,19 @@ describe('rootLogger', () => {
);
});
it('redacts but ignores empty and one-character secrets', () => {
const logger = createRootLogger();
jest.spyOn(logger, 'write');
setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', 'Q', '']);
logger.info('Logging SECRET-1 and SECRET_2 and Q');
expect(logger.write).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Logging [REDACTED] and [REDACTED] and Q',
}),
);
});
describe('createRootLogger', () => {
it('creates a new logger', () => {
const oldLogger = getRootLogger();
@@ -21,7 +21,7 @@ import { coloredFormat } from './formats';
import { escapeRegExp } from '../util/escapeRegExp';
let rootLogger: winston.Logger;
let redactionRegExp: RegExp;
let redactionRegExp: RegExp | undefined;
/** @public */
export function getRootLogger(): winston.Logger {
@@ -34,11 +34,18 @@ export function setRootLogger(newLogger: winston.Logger) {
}
export function setRootLoggerRedactionList(redactionList: string[]) {
if (redactionList.length) {
// Exclude secrets that are empty or just one character in length. These
// typically mean that you are running local dev or tests, or using the
// --lax flag which sets things to just 'x'. So exclude those.
const filtered = redactionList.filter(r => r.length > 1);
if (filtered.length) {
redactionRegExp = new RegExp(
`(${redactionList.map(escapeRegExp).join('|')})`,
`(${filtered.map(escapeRegExp).join('|')})`,
'g',
);
} else {
redactionRegExp = undefined;
}
}
@@ -18,6 +18,7 @@ import aws, { Credentials, S3 } from 'aws-sdk';
import { CredentialsOptions } from 'aws-sdk/lib/credentials';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseFactory,
ReadUrlOptions,
@@ -92,7 +93,7 @@ export class AwsS3UrlReader implements UrlReader {
) {}
/**
* If accesKeyId and secretAccessKey are missing, the standard credentials provider chain will be used:
* If accessKeyId and secretAccessKey are missing, the standard credentials provider chain will be used:
* https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html
*/
private static buildCredentials(
@@ -154,9 +155,10 @@ export class AwsS3UrlReader implements UrlReader {
};
}
const response = this.deps.s3.getObject(params);
const buffer = await getRawBody(response.createReadStream());
const etag = (await response.promise()).ETag;
const request = this.deps.s3.getObject(params);
options?.signal?.addEventListener('abort', () => request.abort());
const buffer = await getRawBody(request.createReadStream());
const etag = (await request.promise()).ETag;
return {
buffer: async () => buffer,
@@ -171,7 +173,10 @@ export class AwsS3UrlReader implements UrlReader {
}
}
async readTree(url: string): Promise<ReadTreeResponse> {
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
try {
const { path, bucket, region } = parseURL(url);
const allObjects: ObjectList = [];
@@ -180,13 +185,13 @@ export class AwsS3UrlReader implements UrlReader {
let output: ListObjectsV2Output;
do {
aws.config.update({ region: region });
output = await this.deps.s3
.listObjectsV2({
Bucket: bucket,
ContinuationToken: continuationToken,
Prefix: path,
})
.promise();
const request = this.deps.s3.listObjectsV2({
Bucket: bucket,
ContinuationToken: continuationToken,
Prefix: path,
});
options?.signal?.addEventListener('abort', () => request.abort());
output = await request.promise();
if (output.Contents) {
output.Contents.forEach(contents => {
allObjects.push(contents);
@@ -55,21 +55,34 @@ export class AzureUrlReader implements UrlReader {
) {}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
// TODO: etag is not implemented yet.
const { signal } = options ?? {};
const builtUrl = getAzureFileFetchUrl(url);
let response: Response;
try {
response = await fetch(
builtUrl,
getAzureRequestOptions(this.integration.config),
);
response = await fetch(builtUrl, {
...getAzureRequestOptions(this.integration.config),
...(signal && { signal }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
if (response.ok && response.status !== 203) {
return Buffer.from(await response.arrayBuffer());
return {
buffer: async () => Buffer.from(await response.arrayBuffer()),
};
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
@@ -79,19 +92,12 @@ export class AzureUrlReader implements UrlReader {
throw new Error(message);
}
async readUrl(
url: string,
_options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
// TODO etag is not implemented yet.
const buffer = await this.read(url);
return { buffer: async () => buffer };
}
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const { etag, filter, signal } = options ?? {};
// TODO: Support filepath based reading tree feature like other providers
// Get latest commit SHA
@@ -109,16 +115,16 @@ export class AzureUrlReader implements UrlReader {
}
const commitSha = (await commitsAzureResponse.json()).value[0].commitId;
if (options?.etag && options.etag === commitSha) {
if (etag && etag === commitSha) {
throw new NotModifiedError();
}
const archiveAzureResponse = await fetch(
getAzureDownloadUrl(url),
getAzureRequestOptions(this.integration.config, {
const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), {
...getAzureRequestOptions(this.integration.config, {
Accept: 'application/zip',
}),
);
...(signal && { signal }),
});
if (!archiveAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`;
if (archiveAzureResponse.status === 404) {
@@ -139,7 +145,7 @@ export class AzureUrlReader implements UrlReader {
return await this.deps.treeResponseFactory.fromZipArchive({
stream: archiveAzureResponse.body as unknown as Readable,
etag: commitSha,
filter: options?.filter,
filter,
subpath,
});
}
@@ -158,6 +164,7 @@ export class AzureUrlReader implements UrlReader {
const tree = await this.readTree(treeUrl.toString(), {
etag: options?.etag,
signal: options?.signal,
filter: p => (matcher ? matcher.match(p) : true),
});
const files = await tree.files();
@@ -77,18 +77,33 @@ export class BitbucketUrlReader implements UrlReader {
}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
// TODO: etag is not supported yet
const { signal } = options ?? {};
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config);
const options = getBitbucketRequestOptions(this.integration.config);
const requestOptions = getBitbucketRequestOptions(this.integration.config);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), options);
response = await fetch(bitbucketUrl.toString(), {
...requestOptions,
...(signal && { signal }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.ok) {
return Buffer.from(await response.arrayBuffer());
return {
buffer: async () => Buffer.from(await response.arrayBuffer()),
};
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
@@ -98,15 +113,6 @@ export class BitbucketUrlReader implements UrlReader {
throw new Error(message);
}
async readUrl(
url: string,
_options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
// TODO etag is not implemented yet.
const buffer = await this.read(url);
return { buffer: async () => buffer };
}
async readTree(
url: string,
options?: ReadTreeOptions,
@@ -77,6 +77,10 @@ describe('FetchUrlReader', () => {
{ host: 'example.com:700' },
{ host: '*.examples.org' },
{ host: '*.examples.org:700' },
{
host: 'foobar.org',
paths: ['/dir1/'],
},
],
},
},
@@ -106,6 +110,9 @@ describe('FetchUrlReader', () => {
expect(predicate(new URL('https://examples.org:700/test'))).toBe(false);
expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true);
expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true);
expect(predicate(new URL('https://foobar.org/dir1/subpath'))).toBe(true);
expect(predicate(new URL('https://foobar.org/dir12'))).toBe(false);
expect(predicate(new URL('https://foobar.org/'))).toBe(false);
});
describe('read', () => {
@@ -24,6 +24,7 @@ import {
SearchResponse,
UrlReader,
} from './types';
import path from 'path';
/**
* A UrlReader that does a plain fetch of the URL.
@@ -39,18 +40,30 @@ export class FetchUrlReader implements UrlReader {
* `host`:
* Either full hostnames to match, or subdomain wildcard matchers with a leading `*`.
* For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not.
*
* `paths`:
* An optional list of paths which are allowed. If the list is omitted all paths are allowed.
*/
static factory: ReaderFactory = ({ config }) => {
const predicates =
config
.getOptionalConfigArray('backend.reading.allow')
?.map(allowConfig => {
const paths = allowConfig.getOptionalStringArray('paths');
const checkPath = paths
? (url: URL) => {
const targetPath = path.posix.normalize(url.pathname);
return paths.some(allowedPath =>
targetPath.startsWith(allowedPath),
);
}
: (_url: URL) => true;
const host = allowConfig.getString('host');
if (host.startsWith('*.')) {
const suffix = host.slice(1);
return (url: URL) => url.host.endsWith(suffix);
return (url: URL) => url.host.endsWith(suffix) && checkPath(url);
}
return (url: URL) => url.host === host;
return (url: URL) => url.host === host && checkPath(url);
}) ?? [];
const reader = new FetchUrlReader();
@@ -73,6 +86,7 @@ export class FetchUrlReader implements UrlReader {
headers: {
...(options?.etag && { 'If-None-Match': options.etag }),
},
signal: options?.signal,
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
@@ -110,6 +110,7 @@ export class GithubUrlReader implements UrlReader {
...(options?.etag && { 'If-None-Match': options.etag }),
Accept: 'application/vnd.github.v3.raw',
},
signal: options?.signal,
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
@@ -164,7 +165,7 @@ export class GithubUrlReader implements UrlReader {
repoDetails.repo.archive_url,
commitSha,
filepath,
{ headers },
{ headers, signal: options?.signal },
options,
);
}
@@ -188,7 +189,7 @@ export class GithubUrlReader implements UrlReader {
repoDetails.repo.archive_url,
commitSha,
filepath,
{ headers },
{ headers, signal: options?.signal },
);
return { files, etag: commitSha };
@@ -66,6 +66,7 @@ export class GitlabUrlReader implements UrlReader {
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
const { etag, signal } = options ?? {};
const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config);
let response: Response;
@@ -73,8 +74,9 @@ export class GitlabUrlReader implements UrlReader {
response = await fetch(builtUrl, {
headers: {
...getGitLabRequestOptions(this.integration.config).headers,
...(options?.etag && { 'If-None-Match': options.etag }),
...(etag && { 'If-None-Match': etag }),
},
...(signal && { signal }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
@@ -102,6 +104,7 @@ export class GitlabUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const { etag, signal } = options ?? {};
const { ref, full_name, filepath } = parseGitUrl(url);
// Use GitLab API to get the default branch
@@ -140,7 +143,10 @@ export class GitlabUrlReader implements UrlReader {
full_name,
)}/repository/commits?${commitsReqParams.toString()}`,
).toString(),
getGitLabRequestOptions(this.integration.config),
{
...getGitLabRequestOptions(this.integration.config),
...(signal && { signal }),
},
);
if (!commitsGitlabResponse.ok) {
const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`;
@@ -152,7 +158,7 @@ export class GitlabUrlReader implements UrlReader {
const commitSha = (await commitsGitlabResponse.json())[0].id;
if (options?.etag && options.etag === commitSha) {
if (etag && etag === commitSha) {
throw new NotModifiedError();
}
@@ -161,7 +167,10 @@ export class GitlabUrlReader implements UrlReader {
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}/repository/archive?sha=${branch}`,
getGitLabRequestOptions(this.integration.config),
{
...getGitLabRequestOptions(this.integration.config),
...(signal && { signal }),
},
);
if (!archiveGitLabResponse.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
@@ -191,6 +200,7 @@ export class GitlabUrlReader implements UrlReader {
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
signal: options?.signal,
filter: path => matcher.match(stripFirstDirectoryFromPath(path)),
});
const files = await tree.files();
@@ -17,6 +17,7 @@
import { Readable } from 'stream';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { AbortSignal } from 'node-abort-controller';
/**
* A generic interface for fetching plain data from URLs.
@@ -101,6 +102,15 @@ export type ReadUrlOptions = {
* of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
@@ -165,6 +175,15 @@ export type ReadTreeOptions = {
* rest of the response along with a new ETag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
@@ -291,6 +310,15 @@ export type SearchOptions = {
* search will return the rest of SearchResponse along with a new etag.
*/
etag?: string;
/**
* An abort signal to pass down to the underlying request.
*
* @remarks
*
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
};
/**
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/json-schema": "^7.0.5",
"@types/yup": "^0.29.13",
"ajv": "^7.0.3",
"json-schema": "^0.3.0",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"uuid": "^8.0.0",
"yup": "^0.32.9"
+2 -2
View File
@@ -37,7 +37,7 @@
"@lerna/package-graph": "^4.0.0",
"@lerna/project": "^4.0.0",
"@octokit/request": "^5.4.12",
"@rollup/plugin-commonjs": "^17.1.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^13.0.0",
"@rollup/plugin-yaml": "^3.0.0",
@@ -81,7 +81,7 @@
"inquirer": "^7.0.4",
"jest": "^26.0.1",
"jest-css-modules": "^2.1.0",
"json-schema": "^0.3.0",
"json-schema": "^0.4.0",
"jest-transform-yaml": "^0.1.1",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.4.2",
@@ -263,7 +263,6 @@ export default async (cmd: Command) => {
const pluginDir = isMonoRepo
? paths.resolveTargetRoot('plugins', pluginId)
: paths.resolveTargetRoot(pluginId);
const ownerIds = parseOwnerIds(answers.owner);
const { version: pluginVersion } = isMonoRepo
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
: { version: '0.1.0' };
@@ -318,12 +317,8 @@ export default async (cmd: Command) => {
await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (ownerIds && ownerIds.length) {
await addCodeownersEntry(
codeownersPath!,
`/plugins/${pluginId}`,
ownerIds,
);
if (answers.owner) {
await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner);
}
Task.log();
+135
View File
@@ -0,0 +1,135 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'os';
import fs from 'fs-extra';
import { join as joinPath } from 'path';
import { Command } from 'commander';
import { FactoryRegistry } from '../../lib/create/FactoryRegistry';
import { paths } from '../../lib/paths';
import { assertError } from '@backstage/errors';
import { Task } from '../../lib/tasks';
function parseOptions(optionStrings: string[]): Record<string, string> {
const options: Record<string, string> = {};
for (const str of optionStrings) {
const [key] = str.split('=', 1);
const value = str.slice(key.length + 1);
if (!key || str[key.length] !== '=') {
throw new Error(
`Invalid option '${str}', must be of the format <key>=<value>`,
);
}
options[key] = value;
}
return options;
}
export default async (cmd: Command) => {
const cmdOpts = cmd.opts();
const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select);
const providedOptions = parseOptions(cmdOpts.option);
const options = await FactoryRegistry.populateOptions(
factory,
providedOptions,
);
let isMonoRepo = false;
try {
const rootPackageJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
if (rootPackageJson.workspaces) {
isMonoRepo = true;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
let defaultVersion = '0.1.0';
try {
const rootLernaJson = await fs.readJson(
paths.resolveTargetRoot('lerna.json'),
);
if (rootLernaJson.version) {
defaultVersion = rootLernaJson.version;
}
} catch (error) {
assertError(error);
if (error.code !== 'ENOENT') {
throw error;
}
}
const tempDirs = new Array<string>();
async function createTemporaryDirectory(name: string): Promise<string> {
const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name));
tempDirs.push(dir);
return dir;
}
let modified = false;
try {
await factory.create(options, {
isMonoRepo,
defaultVersion,
scope: cmdOpts.scope?.replace(/^@/, ''),
npmRegistry: cmdOpts.npmRegistry,
private: Boolean(cmdOpts.private),
createTemporaryDirectory,
markAsModified() {
modified = true;
},
});
Task.log();
Task.log(`🎉 Successfully created ${factory.name}`);
Task.log();
} catch (error) {
assertError(error);
Task.error(error.message);
if (modified) {
Task.log('It seems that something went wrong in the creation process 🤔');
Task.log();
Task.log(
'We have left the changes that were made intact in case you want to',
);
Task.log(
'continue manually, but you can also revert the changes and try again.',
);
Task.error(`🔥 Failed to create ${factory.name}!`);
}
} finally {
for (const dir of tempDirs) {
try {
await fs.remove(dir);
} catch (error) {
console.error(
`Failed to remove temporary directory '${dir}', ${error}`,
);
}
}
}
};
+24
View File
@@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) {
.option(...configOption)
.action(lazy(() => import('./backend/dev').then(m => m.default)));
program
.command('create')
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./create/create').then(m => m.default)));
program
.command('create-plugin')
.option(
@@ -177,9 +177,9 @@ describe('removePlugin', () => {
fse.readFileSync(mockedCodeownersPath, 'utf8'),
);
await addCodeownersEntry(
testFilePath!,
path.join('plugins', testPluginName),
['@thisIsAtestTeam', 'test@gmail.com'],
'@thisIsAtestTeam test@gmail.com',
testFilePath,
);
await removePluginFromCodeOwners(testFilePath, testPluginName);
expect(testFileContent).toBe(codeOwnersFileContent);
+24 -8
View File
@@ -16,6 +16,7 @@
import fs from 'fs-extra';
import path from 'path';
import { paths } from '../paths';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
@@ -30,14 +31,14 @@ type CodeownersEntry = {
export async function getCodeownersFilePath(
rootDir: string,
): Promise<string | undefined> {
const paths = [
const possiblePaths = [
path.join(rootDir, '.github', 'CODEOWNERS'),
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
path.join(rootDir, 'docs', 'CODEOWNERS'),
path.join(rootDir, 'CODEOWNERS'),
];
for (const p of paths) {
for (const p of possiblePaths) {
if (await fs.pathExists(p)) {
return p;
}
@@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean {
}
export function parseOwnerIds(
spaceSeparatedOwnerIds: string,
spaceSeparatedOwnerIds: string | undefined,
): string[] | undefined {
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
return undefined;
@@ -70,11 +71,24 @@ export function parseOwnerIds(
}
export async function addCodeownersEntry(
codeownersFilePath: string,
ownedPath: string,
ownerIds: string[],
): Promise<void> {
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
ownerStr: string,
codeownersFilePath?: string,
): Promise<boolean> {
const ownerIds = parseOwnerIds(ownerStr);
if (!ownerIds || ownerIds.length === 0) {
return false;
}
let filePath = codeownersFilePath;
if (!filePath) {
filePath = await getCodeownersFilePath(paths.targetRoot);
if (!filePath) {
return false;
}
}
const allLines = (await fs.readFile(filePath, 'utf8')).split('\n');
// Only keep comments from the top of the file
const commentLines = [];
@@ -117,5 +131,7 @@ export async function addCodeownersEntry(
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
await fs.writeFile(filePath, newLines.join('\n'), 'utf8');
return true;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export async function loadCliConfig(options: Options) {
packagePaths: [paths.resolveTargetRoot('package.json')],
});
const appConfigs = await loadConfig({
const { appConfigs } = await loadConfig({
experimentalEnvFunc: options.mockEnv
? async name => process.env[name] || 'x'
: undefined,
@@ -0,0 +1,126 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import inquirer from 'inquirer';
import { AnyFactory, Prompt } from './types';
import * as factories from './factories';
import partition from 'lodash/partition';
function applyPromptMessageTransforms<T>(
prompt: Prompt<T>,
transforms: {
message: (msg: string) => string;
error: (msg: string) => string;
},
): Prompt<T> {
return {
...prompt,
message:
prompt.message &&
(async answers => {
if (typeof prompt.message === 'function') {
return transforms.message(await prompt.message(answers));
}
return transforms.message(await prompt.message!);
}),
validate:
prompt.validate &&
(async (...args) => {
const result = await prompt.validate!(...args);
if (typeof result === 'string') {
return transforms.error(result);
}
return result;
}),
};
}
export class FactoryRegistry {
private static factoryMap = new Map<string, AnyFactory>(
Object.values(factories).map(factory => [factory.name, factory]),
);
static async interactiveSelect(preselected?: string): Promise<AnyFactory> {
let selected = preselected;
if (!selected) {
const answers = await inquirer.prompt<{ name: string }>([
{
type: 'list',
name: 'name',
message: 'What do you want to create?',
choices: Array.from(this.factoryMap.values()).map(factory => ({
name: `${factory.name} - ${factory.description}`,
value: factory.name,
})),
},
]);
selected = answers.name;
}
const factory = this.factoryMap.get(selected);
if (!factory) {
throw new Error(`Unknown selection '${selected}'`);
}
return factory;
}
static async populateOptions(
factory: AnyFactory,
provided: Record<string, string>,
): Promise<Record<string, string>> {
let currentOptions = provided;
if (factory.optionsDiscovery) {
const discoveredOptions = await factory.optionsDiscovery();
currentOptions = {
...currentOptions,
...(discoveredOptions as Record<string, string>),
};
}
if (factory.optionsPrompts) {
const [hasAnswers, needsAnswers] = partition(
factory.optionsPrompts,
option => option.name in currentOptions,
);
for (const option of hasAnswers) {
const value = provided[option.name];
if (option.validate) {
const result = option.validate(value);
if (result !== true) {
throw new Error(`Invalid option '${option.name}'. ${result}`);
}
}
}
currentOptions = await inquirer.prompt(
needsAnswers.map(option =>
applyPromptMessageTransforms(option, {
message: chalk.blue,
error: chalk.red,
}),
),
currentOptions,
);
}
return currentOptions;
}
}
@@ -0,0 +1,115 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { sep, resolve as resolvePath } from 'path';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { backendPlugin } from './backendPlugin';
describe('backendPlugin factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a backend plugin', async () => {
mockFs({
'/root': {
packages: {
backend: {
'package.json': JSON.stringify({}),
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(backendPlugin, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await backendPlugin.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test-backend',
'Checking Prerequisites:',
`availability plugins${sep}test-backend`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating README.md.hbs',
'templating package.json.hbs',
'copying tsconfig.json',
'copying index.ts',
'templating run.ts.hbs',
'copying setupTests.ts',
'copying router.test.ts',
'copying router.ts',
'templating standaloneServer.ts.hbs',
'Installing:',
`moving plugins${sep}test-backend`,
'backend adding dependency',
]);
await expect(
fs.readJson('/root/packages/backend/package.json'),
).resolves.toEqual({
dependencies: {
'backstage-plugin-test-backend': '^1.0.0',
},
});
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test-backend'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test-backend'),
optional: true,
});
});
});
@@ -0,0 +1,89 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const backendPlugin = createFactory<Options>({
name: 'backend-plugin',
description: 'A new backend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const id = `${options.id}-backend`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-backend-plugin',
values: {
id,
name,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
await Task.forItem('backend', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/backend/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,58 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Prompt } from '../../types';
import { parseOwnerIds } from '../../../codeowners';
export function pluginIdPrompt(): Prompt<{ id: string }> {
return {
type: 'input',
name: 'id',
message: 'Enter the ID of the plugin [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the ID of the plugin';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
};
}
export function ownerPrompt(): Prompt<{
owner?: string;
codeOwnersPath?: string;
}> {
return {
type: 'input',
name: 'owner',
message: 'Enter an owner to add to CODEOWNERS [optional]',
when: opts => Boolean(opts.codeOwnersPath),
validate: (value: string) => {
if (!value) {
return true;
}
const ownerIds = parseOwnerIds(value);
if (!ownerIds) {
return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).';
}
return true;
},
};
}
@@ -0,0 +1,118 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { sep } from 'path';
import { createMockOutputStream, mockPaths } from './testUtils';
import { CreateContext } from '../../types';
import { executePluginPackageTemplate } from './tasks';
mockPaths({
ownDir: '/own',
targetRoot: '/root',
});
describe('executePluginPackageTemplate', () => {
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should execute template', async () => {
mockFs({
'/root': {
'yarn.lock': `
some-package@^1.1.0:
version "1.5.0"
`,
},
'/own': {
templates: {
'test-template': {
'package.json.hbs': `
{
"name": "my-{{id}}-plugin",
{{#if makePrivate}}
"private": true,
{{/if}}
"description": "testing",
"dependencies": {
"some-package": "{{ versionQuery 'some-package' '1.3.0' }}",
"other-package": "{{ versionQuery 'other-package' '2.3.0' }}"
}
}
`,
subdir: {
'templated.txt.hbs': 'Hello {{id}}!',
'not-templated.txt': 'Hello {{id}}!',
},
},
},
},
});
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
let modified = false;
await executePluginPackageTemplate(
{
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
markAsModified: () => {
modified = true;
},
} as CreateContext,
{
templateName: 'test-template',
targetDir: '/target',
values: {
id: 'testing',
makePrivate: true,
},
},
);
expect(modified).toBe(true);
expect(output).toEqual([
'Checking Prerequisites:',
`availability ..${sep}target`,
'creating temp dir',
'Executing Template:',
'templating package.json.hbs',
'copying not-templated.txt',
'templating templated.txt.hbs',
'Installing:',
`moving ..${sep}target`,
]);
await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{
"name": "my-testing-plugin",
"private": true,
"description": "testing",
"dependencies": {
"some-package": "^1.1.0",
"other-package": "^2.3.0"
}
}
`);
await expect(
fs.readFile('/target/subdir/templated.txt', 'utf8'),
).resolves.toBe('Hello testing!');
await expect(
fs.readFile('/target/subdir/not-templated.txt', 'utf8'),
).resolves.toBe('Hello {{id}}!');
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import { resolve as resolvePath, relative as relativePath } from 'path';
import { paths } from '../../../paths';
import { Task, templatingTask } from '../../../tasks';
import { Lockfile } from '../../../versioning';
import { createPackageVersionProvider } from '../../../version';
import { CreateContext } from '../../types';
export async function executePluginPackageTemplate(
ctx: CreateContext,
options: {
templateName: string;
targetDir: string;
values: Record<string, unknown>;
},
) {
const { targetDir } = options;
let lockfile: Lockfile | undefined;
try {
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
} catch {
/* ignored */
}
Task.section('Checking Prerequisites');
const shortPluginDir = relativePath(paths.targetRoot, targetDir);
await Task.forItem('availability', shortPluginDir, async () => {
if (await fs.pathExists(targetDir)) {
throw new Error(
`A package with the same plugin ID already exists at ${chalk.cyan(
shortPluginDir,
)}. Please try again with a different ID.`,
);
}
});
const tempDir = await Task.forItem('creating', 'temp dir', async () => {
return await ctx.createTemporaryDirectory('backstage-create');
});
Task.section('Executing Template');
await templatingTask(
paths.resolveOwn('templates', options.templateName),
tempDir,
options.values,
createPackageVersionProvider(lockfile),
);
// Format package.json if it exists
const pkgJsonPath = resolvePath(tempDir, 'package.json');
if (await fs.pathExists(pkgJsonPath)) {
const pkgJson = await fs.readJson(pkgJsonPath);
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
Task.section('Installing');
await Task.forItem('moving', shortPluginDir, async () => {
await fs.move(tempDir, targetDir).catch(error => {
throw new Error(
`Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`,
);
});
});
ctx.markAsModified();
}
@@ -0,0 +1,75 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-control-regex */
import { WriteStream } from 'tty';
import { resolve as resolvePath } from 'path';
import { paths } from '../../../paths';
export function mockPaths(options: {
ownDir?: string;
ownRoot?: string;
targetDir?: string;
targetRoot?: string;
}): void {
const { ownDir, ownRoot, targetDir, targetRoot } = options;
if (ownDir) {
paths.ownDir = ownDir;
jest
.spyOn(paths, 'resolveOwn')
.mockImplementation((...ps) => resolvePath(ownDir, ...ps));
}
if (ownRoot) {
jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot);
jest
.spyOn(paths, 'resolveOwnRoot')
.mockImplementation((...ps) => resolvePath(ownRoot, ...ps));
}
if (targetDir) {
paths.targetDir = targetDir;
jest
.spyOn(paths, 'resolveTarget')
.mockImplementation((...ps) => resolvePath(targetDir, ...ps));
}
if (targetRoot) {
jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...ps) => resolvePath(targetRoot, ...ps));
}
}
export function createMockOutputStream() {
const output = new Array<string>();
return [
output,
{
cursorTo: () => {},
clearLine: () => {},
moveCursor: () => {},
write: (msg: string) => {
let clean = msg;
// Remove terminal color escape sequences
clean = clean.replace(/\x1B\[\d\dm/g, '');
// Remove any non-ascii
clean = clean.replace(/[^\x00-\x7F]+/g, '');
clean = clean.trim();
output.push(clean);
},
} as unknown as WriteStream & { fd: any },
] as const;
}
@@ -0,0 +1,217 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { sep, resolve as resolvePath } from 'path';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { frontendPlugin } from './frontendPlugin';
const appTsxContent = `
import { createApp } from '@backstage/app-defaults';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
</FlatRoutes>
)
`;
describe('frontendPlugin factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a frontend plugin', async () => {
mockFs({
'/root': {
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test',
'Checking Prerequisites:',
`availability plugins${sep}test`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating README.md.hbs',
'templating package.json.hbs',
'copying tsconfig.json',
'templating index.tsx.hbs',
'templating index.ts.hbs',
'templating plugin.test.ts.hbs',
'templating plugin.ts.hbs',
'templating routes.ts.hbs',
'copying setupTests.ts',
'templating ExampleComponent.test.tsx.hbs',
'templating ExampleComponent.tsx.hbs',
'copying index.ts',
'templating ExampleFetchComponent.test.tsx.hbs',
'templating ExampleFetchComponent.tsx.hbs',
'copying index.ts',
'Installing:',
`moving plugins${sep}test`,
'app adding dependency',
'app adding import',
]);
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'backstage-plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from 'backstage-plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
});
it('should create a frontend plugin with more options and codeowners', async () => {
mockFs({
'/root': {
CODEOWNERS: '',
packages: {
app: {
'package.json': JSON.stringify({}),
src: {
'App.tsx': appTsxContent,
},
},
},
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
id: 'test',
owner: '@test-user',
});
const [, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await frontendPlugin.create(options, {
scope: 'internal',
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
await expect(
fs.readJson('/root/packages/app/package.json'),
).resolves.toEqual({
dependencies: {
'@internal/plugin-test': '^1.0.0',
},
});
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
.toBe(`
import { createApp } from '@backstage/app-defaults';
import { TestPage } from '@internal/plugin-test';
const router = (
<FlatRoutes>
<Route path="/" element={<Home />} />
<Route path="/test" element={<TestPage />} />
</FlatRoutes>
)
`);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test'),
optional: true,
});
});
});
@@ -0,0 +1,129 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { addPackageDependency, Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const frontendPlugin = createFactory<Options>({
name: 'plugin',
description: 'A new frontend plugin',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const name = ctx.scope
? `@${ctx.scope}/plugin-${id}`
: `backstage-plugin-${id}`;
const extensionName = `${upperFirst(camelCase(id))}Page`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', id)
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-plugin',
values: {
id,
name,
extensionName,
pluginVar: `${camelCase(id)}Plugin`,
pluginVersion: ctx.defaultVersion,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
},
});
if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) {
await Task.forItem('app', 'adding dependency', async () => {
await addPackageDependency(
paths.resolveTargetRoot('packages/app/package.json'),
{
dependencies: {
[name]: `^${ctx.defaultVersion}`,
},
},
);
});
await Task.forItem('app', 'adding import', async () => {
const pluginsFilePath = paths.resolveTargetRoot(
'packages/app/src/App.tsx',
);
if (!(await fs.pathExists(pluginsFilePath))) {
return;
}
const content = await fs.readFile(pluginsFilePath, 'utf8');
const revLines = content.split('\n').reverse();
const lastImportIndex = revLines.findIndex(line =>
line.match(/ from ("|').*("|')/),
);
const lastRouteIndex = revLines.findIndex(line =>
line.match(/<\/FlatRoutes/),
);
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
const importLine = `import { ${extensionName} } from '${name}';`;
if (!content.includes(importLine)) {
revLines.splice(lastImportIndex, 0, importLine);
}
const componentLine = `<Route path="/${id}" element={<${extensionName} />} />`;
if (!content.includes(componentLine)) {
const [indentation] =
revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine);
}
const newContent = revLines.reverse().join('\n');
await fs.writeFile(pluginsFilePath, newContent, 'utf8');
}
});
}
if (options.owner) {
await addCodeownersEntry(`/plugins/${id}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,20 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { frontendPlugin } from './frontendPlugin';
export { backendPlugin } from './backendPlugin';
export { pluginCommon } from './pluginCommon';
export { scaffolderModule } from './scaffolderModule';
@@ -0,0 +1,108 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { sep, resolve as resolvePath } from 'path';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { pluginCommon } from './pluginCommon';
describe('pluginCommon factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a common plugin package', async () => {
mockFs({
'/root': {
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(pluginCommon, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await pluginCommon.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: () => fs.mkdtemp('test'),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating backend plugin backstage-plugin-test-common',
'Checking Prerequisites:',
`availability plugins${sep}test-common`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating README.md.hbs',
'templating package.json.hbs',
'copying tsconfig.json',
'templating index.ts.hbs',
'copying setupTests.ts',
'Installing:',
`moving plugins${sep}test-common`,
]);
await expect(
fs.readJson('/root/plugins/test-common/package.json'),
).resolves.toEqual(
expect.objectContaining({
name: 'backstage-plugin-test-common',
description: 'Common functionalities for the test plugin',
private: true,
version: '1.0.0',
}),
);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/test-common'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/test-common'),
optional: true,
});
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { Task } from '../../tasks';
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const pluginCommon = createFactory<Options>({
name: 'plugin-common',
description: 'A new isomorphic common plugin package',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const suffix = `${id}-common`;
const name = ctx.scope
? `@${ctx.scope}/plugin-${suffix}`
: `backstage-plugin-${suffix}`;
Task.log();
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', suffix)
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'default-common-plugin-package',
values: {
id,
name,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
pluginVersion: ctx.defaultVersion,
},
});
if (options.owner) {
await addCodeownersEntry(`/plugins/${suffix}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
@@ -0,0 +1,111 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { sep, resolve as resolvePath } from 'path';
import { paths } from '../../paths';
import { Task } from '../../tasks';
import { FactoryRegistry } from '../FactoryRegistry';
import { createMockOutputStream, mockPaths } from './common/testUtils';
import { scaffolderModule } from './scaffolderModule';
describe('scaffolderModule factory', () => {
beforeEach(() => {
mockPaths({
targetRoot: '/root',
});
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should create a scaffolder backend module package', async () => {
mockFs({
'/root': {
plugins: mockFs.directory(),
},
[paths.resolveOwn('templates')]: mockFs.load(
paths.resolveOwn('templates'),
),
});
const options = await FactoryRegistry.populateOptions(scaffolderModule, {
id: 'test',
});
let modified = false;
const [output, mockStream] = createMockOutputStream();
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
jest.spyOn(Task, 'forCommand').mockResolvedValue();
await scaffolderModule.create(options, {
private: true,
isMonoRepo: true,
defaultVersion: '1.0.0',
markAsModified: () => {
modified = true;
},
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
});
expect(modified).toBe(true);
expect(output).toEqual([
'',
'Creating module backstage-plugin-scaffolder-backend-module-test',
'Checking Prerequisites:',
`availability plugins${sep}scaffolder-backend-module-test`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating README.md.hbs',
'templating package.json.hbs',
'copying tsconfig.json',
'templating index.ts.hbs',
'copying index.ts',
'copying example.test.ts',
'copying example.ts',
'copying index.ts',
'Installing:',
`moving plugins${sep}scaffolder-backend-module-test`,
]);
await expect(
fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'),
).resolves.toEqual(
expect.objectContaining({
name: 'backstage-plugin-scaffolder-backend-module-test',
description: 'The test module for @backstage/plugin-scaffolder-backend',
private: true,
version: '1.0.0',
}),
);
expect(Task.forCommand).toHaveBeenCalledTimes(2);
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
optional: true,
});
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
optional: true,
});
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import chalk from 'chalk';
import { paths } from '../../paths';
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
import { createFactory, CreateContext } from '../types';
import { Task } from '../../tasks';
import { ownerPrompt } from './common/prompts';
import { executePluginPackageTemplate } from './common/tasks';
type Options = {
id: string;
owner?: string;
codeOwnersPath?: string;
};
export const scaffolderModule = createFactory<Options>({
name: 'scaffolder-module',
description:
'An module exporting custom actions for @backstage/plugin-scaffolder-backend',
optionsDiscovery: async () => ({
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
}),
optionsPrompts: [
{
type: 'input',
name: 'id',
message: 'Enter the name of the module [required]',
validate: (value: string) => {
if (!value) {
return 'Please enter the name of the module';
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return 'Module names must be lowercase and contain only letters, digits, and dashes.';
}
return true;
},
},
ownerPrompt(),
],
async create(options: Options, ctx: CreateContext) {
const { id } = options;
const slug = `scaffolder-backend-module-${id}`;
let name = `backstage-plugin-${slug}`;
if (ctx.scope) {
if (ctx.scope === 'backstage') {
name = `@backstage/plugin-${slug}`;
} else {
name = `@${ctx.scope}/backstage-plugin-${slug}`;
}
}
Task.log();
Task.log(`Creating module ${chalk.cyan(name)}`);
const targetDir = ctx.isMonoRepo
? paths.resolveTargetRoot('plugins', slug)
: paths.resolveTargetRoot(`backstage-plugin-${slug}`);
await executePluginPackageTemplate(ctx, {
targetDir,
templateName: 'scaffolder-module',
values: {
id,
name,
privatePackage: ctx.private,
npmRegistry: ctx.npmRegistry,
pluginVersion: ctx.defaultVersion,
},
});
if (options.owner) {
await addCodeownersEntry(`/plugins/${slug}`, options.owner);
}
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
await Task.forCommand('yarn lint --fix', {
cwd: targetDir,
optional: true,
});
},
});
+77
View File
@@ -0,0 +1,77 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DistinctQuestion } from 'inquirer';
export interface CreateContext {
/** The package scope to use for new packages */
scope?: string;
/** The NPM registry to use for new packages */
npmRegistry?: string;
/** Whether new packages should be marked as private */
private: boolean;
/** Whether we are creating something in a monorepo or not */
isMonoRepo: boolean;
/** The default version to use for new packages */
defaultVersion: string;
/** Creates a temporary directory. This will always be deleted after creation is done. */
createTemporaryDirectory(name: string): Promise<string>;
/** Signal that the creation process got to a point where permanent modifications were made */
markAsModified(): void;
}
export type AnyOptions = Record<string, string>;
export type Prompt<TOptions> = DistinctQuestion<TOptions> & { name: string };
export interface Factory<TOptions extends AnyOptions> {
/**
* The name used for this factory.
*/
name: string;
/**
* A description that describes what this factory creates to the user.
*/
description: string;
/**
* An optional options discovery step that is run
* before the prompts to potentially fill in some of the options.
*/
optionsDiscovery?(): Promise<Partial<TOptions>>;
/**
* Inquirer prompts that will be filled in either interactively or
* through command line arguments.
*/
optionsPrompts?: ReadonlyArray<Prompt<TOptions>>;
/**
* The main method of the factory that handles creation.
*/
create(options: TOptions, context?: CreateContext): Promise<void>;
}
export type AnyFactory = Factory<AnyOptions>;
export function createFactory<TOptions extends AnyOptions>(
config: Factory<TOptions>,
): AnyFactory {
return config as AnyFactory;
}
+11 -10
View File
@@ -106,18 +106,19 @@ export async function createDistWorkspace(
if (options.buildDependencies) {
const exclude = options.buildExcludes ?? [];
const scopeArgs = targets
.filter(target => !exclude.includes(target.name))
.flatMap(target => ['--scope', target.name]);
const lernaArgs =
options.parallel && Number.isInteger(options.parallel)
? ['--concurrency', options.parallel.toString()]
: [];
const toBuild = targets.filter(target => !exclude.includes(target.name));
if (toBuild.length > 0) {
const scopeArgs = toBuild.flatMap(target => ['--scope', target.name]);
const lernaArgs =
options.parallel && Number.isInteger(options.parallel)
? ['--concurrency', options.parallel.toString()]
: [];
await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], {
cwd: paths.targetRoot,
});
await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], {
cwd: paths.targetRoot,
});
}
}
await moveToDistWorkspace(targetDir, targets);
+83 -8
View File
@@ -18,35 +18,40 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { promisify } from 'util';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
import { assertError } from '@backstage/errors';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
export class Task {
static log(name: string = '') {
process.stdout.write(`${chalk.green(name)}\n`);
process.stderr.write(`${chalk.green(name)}\n`);
}
static error(message: string = '') {
process.stdout.write(`\n${chalk.red(message)}\n\n`);
process.stderr.write(`\n${chalk.red(message)}\n\n`);
}
static section(name: string) {
const title = chalk.green(`${name}:`);
process.stdout.write(`\n ${title}\n`);
process.stderr.write(`\n ${title}\n`);
}
static exit(code: number = 0) {
process.exit(code);
}
static async forItem(
static async forItem<T = void>(
task: string,
item: string,
taskFunc: () => Promise<void>,
): Promise<void> {
taskFunc: () => Promise<T>,
): Promise<T> {
const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH));
const spinner = ora({
@@ -56,13 +61,40 @@ export class Task {
}).start();
try {
await taskFunc();
const result = await taskFunc();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}
static async forCommand(
command: string,
options?: { cwd?: string; optional?: boolean },
) {
try {
await Task.forItem('executing', command, async () => {
await exec(command, { cwd: options?.cwd });
});
} catch (error) {
assertError(error);
if (error.stderr) {
process.stderr.write(error.stderr as Buffer);
}
if (error.stdout) {
process.stdout.write(error.stdout as Buffer);
}
if (options?.optional) {
Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`);
} else {
throw new Error(
`Failed to execute command '${chalk.cyan(command)}', ${error}`,
);
}
}
}
}
export async function templatingTask(
@@ -85,7 +117,9 @@ export async function templatingTask(
const destination = destinationFile.replace(/\.hbs$/, '');
const template = await fs.readFile(file);
const compiled = handlebars.compile(template.toString());
const compiled = handlebars.compile(template.toString(), {
strict: true,
});
const contents = compiled(
{ name: basename(destination), ...context },
{
@@ -122,3 +156,44 @@ export async function templatingTask(
}
}
}
export async function addPackageDependency(
path: string,
options: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
},
) {
try {
const pkgJson = await fs.readJson(path);
const normalize = (obj: Record<string, string>) => {
if (Object.keys(obj).length === 0) {
return undefined;
}
return Object.fromEntries(
Object.keys(obj)
.sort()
.map(key => [key, obj[key]]),
);
};
pkgJson.dependencies = normalize({
...pkgJson.dependencies,
...options.dependencies,
});
pkgJson.devDependencies = normalize({
...pkgJson.devDependencies,
...options.devDependencies,
});
pkgJson.peerDependencies = normalize({
...pkgJson.peerDependencies,
...options.peerDependencies,
});
await fs.writeJson(path, pkgJson, { spaces: 2 });
} catch (error) {
throw new Error(`Failed to add package dependencies, ${error}`);
}
}
+2
View File
@@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso
import { version as devUtils } from '@backstage/dev-utils/package.json';
import { version as testUtils } from '@backstage/test-utils/package.json';
import { version as theme } from '@backstage/theme/package.json';
import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json';
export const packageVersions: Record<string, string> = {
'@backstage/backend-common': backendCommon,
@@ -53,6 +54,7 @@ export const packageVersions: Record<string, string> = {
'@backstage/dev-utils': devUtils,
'@backstage/test-utils': testUtils,
'@backstage/theme': theme,
'@backstage/plugin-scaffolder-backend': scaffolderBackend,
};
export function findVersion() {
@@ -4,41 +4,43 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{/if}}
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/config": "{{versionQuery '@backstage/config'}}",
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
"express": "{{versionQuery 'express' '4.17.1'}}",
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
"winston": "{{versionQuery 'winston' '3.2.1'}}",
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
"yn": "{{versionQuery 'yn' '4.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
"msw": "{{versionQuery 'msw' '0.35.0'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,5 @@
# {{name}}
Welcome to the common package for the {{id}} plugin!
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,34 @@
{
"name": "{{name}}",
"description": "Common functionalities for the {{id}} plugin",
"version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,19 @@
/***/
/**
* Common functionalities for the {{id}} plugin.
*
* @packageDocumentation
*/
/**
* In this package you might for example declare types that are common
* between the frontend and backend plugin packages.
*/
export type CommonType = {
field: string
}
/**
* Or you might declare some common constants.
*/
export const COMMON_CONSTANT = 1
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": "."
}
}
@@ -4,10 +4,12 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.esm.js",
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,5 @@
# {{name}}
The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend).
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,37 @@
{
"name": "{{name}}",
"description": "The {{id}} module for @backstage/plugin-scaffolder-backend",
"version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}}
"private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}}
"registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli build --output cjs,types",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}"
},
"devDependencies": {
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist"
]
}
@@ -0,0 +1,50 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PassThrough } from 'stream';
import { createAcmeExampleAction } from './example';
import { getVoidLogger } from '@backstage/backend-common';
describe('acme:example', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should call action', async () => {
const action = createAcmeExampleAction();
const logger = getVoidLogger();
jest.spyOn(logger, 'info');
await action.handler({
input: {
myParameter: 'test',
},
workspacePath: '/tmp',
logger,
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory() {
// Usage of mock-fs is recommended for testing of filesystem operations
throw new Error('Not implemented');
},
});
expect(logger.info).toHaveBeenCalledWith(
'Running example template with parameters: test',
);
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
/**
* Creates an `acme:example` Scaffolder action.
*
* @remarks
*
* See {@link https://example.com} for more information.
*
* @public
*/
export function createAcmeExampleAction() {
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
myParameter: string;
}>({
id: 'acme:example',
description: 'Runs Yeoman on an installed Yeoman generator',
schema: {
input: {
type: 'object',
required: ['myParameter'],
properties: {
myParameter: {
title: 'An example parameter',
description: 'This is the schema for our example parameter',
type: 'string',
},
},
},
},
async handler(ctx) {
ctx.logger.info(
`Running example template with parameters: ${ctx.input.myParameter}`,
);
await new Promise(resolve => setTimeout(resolve, 1000));
},
});
}
@@ -0,0 +1 @@
export { createAcmeExampleAction } from './example';
@@ -0,0 +1 @@
export * from './example';
@@ -0,0 +1,8 @@
/***/
/**
* The {{id}} module for @backstage/plugin-scaffolder-backend.
*
* @packageDocumentation
*/
export * from './actions';

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