Merge branch 'backstage:master' into topic/add-entity-pull-requests
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kafka-backend': patch
|
||||
---
|
||||
|
||||
Update Kafka configuration types
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-user-settings': patch
|
||||
---
|
||||
|
||||
Add Props Icon for Sidebar Item SidebarSearchField and Settings
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Refactor and add regression tests for create-app tasks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Paths can be specified in backend.reading.allow to further restrict allowed targets
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Skip empty file names when scaffolding with nunjucks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Adjust entity query construction to ensure sub-queries are always isolated from one another.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Add `AbortSignal` support to `UrlReader`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Deprecated the `BackstagePluginWithAnyOutput` type.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Start exporting and marking several types as public to address errors in the API report.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added a scaffolder backend module template for the `create` command.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
Remove exports of unused types(`RouteOptions` and `RoutePath`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
adds getDefaultProcessor method to CatalogBuilder
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types.
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +63,7 @@ Debounce
|
||||
declaratively
|
||||
deduplicated
|
||||
deps
|
||||
dependabot
|
||||
destructured
|
||||
dev
|
||||
devops
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+8
@@ -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[];
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user