diff --git a/.changeset/brave-rats-obey.md b/.changeset/brave-rats-obey.md
new file mode 100644
index 0000000000..a09d922763
--- /dev/null
+++ b/.changeset/brave-rats-obey.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app-backend': patch
+---
+
+Warn if the app-backend can't start-up because the static directory that should be served is unavailable.
diff --git a/.changeset/breezy-cobras-deny.md b/.changeset/breezy-cobras-deny.md
deleted file mode 100644
index 5f035c05ce..0000000000
--- a/.changeset/breezy-cobras-deny.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/core': patch
----
-
-Clear sidebar search field once a search is executed
diff --git a/.changeset/chilled-zoos-rush.md b/.changeset/chilled-zoos-rush.md
new file mode 100644
index 0000000000..6830cb7fcf
--- /dev/null
+++ b/.changeset/chilled-zoos-rush.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-api-docs': minor
+---
+
+APIs now have real entity pages that are customizable in the app.
+Therefore the old entity page from this plugin is removed.
+See the `packages/app` on how to create and customize the API entity page.
diff --git a/.changeset/clever-moons-shake.md b/.changeset/clever-moons-shake.md
deleted file mode 100644
index 0fb6c690c2..0000000000
--- a/.changeset/clever-moons-shake.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/backend-common': patch
----
-
-Added readTree support to AzureUrlReader
diff --git a/.changeset/cli-links.md b/.changeset/cli-links.md
deleted file mode 100644
index 0a9779de93..0000000000
--- a/.changeset/cli-links.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/cli': patch
----
-
-The CLI now detects and transforms linked packages. You can link in external packages by adding them to both the `lerna.json` and `package.json` workspace paths.
diff --git a/.changeset/cost-insights-quiet-bikes-occur.md b/.changeset/cost-insights-quiet-bikes-occur.md
deleted file mode 100644
index aac283bbd4..0000000000
--- a/.changeset/cost-insights-quiet-bikes-occur.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-cost-insights': patch
----
-
-fix product icon configuration
diff --git a/.changeset/cost-insights-tiny-llamas-perform.md b/.changeset/cost-insights-tiny-llamas-perform.md
deleted file mode 100644
index c81c28f8f8..0000000000
--- a/.changeset/cost-insights-tiny-llamas-perform.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-cost-insights': patch
----
-
-truncate large percentages > 1000%
diff --git a/.changeset/curly-yaks-invite.md b/.changeset/curly-yaks-invite.md
deleted file mode 100644
index b9191a3895..0000000000
--- a/.changeset/curly-yaks-invite.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-catalog-backend': patch
----
-
-Add support for reading groups and users from the Microsoft Graph API.
diff --git a/.changeset/dull-pans-sip.md b/.changeset/dull-pans-sip.md
deleted file mode 100644
index 13616c1f8d..0000000000
--- a/.changeset/dull-pans-sip.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@backstage/plugin-scaffolder': patch
-'@backstage/plugin-scaffolder-backend': patch
----
-
-Move constructing the catalog-info.yaml URL for scaffolded components to the publishers
diff --git a/.changeset/empty-kids-look.md b/.changeset/empty-kids-look.md
deleted file mode 100644
index 680cd7f25c..0000000000
--- a/.changeset/empty-kids-look.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-register-component': patch
----
-
-Remove catalog link on validate popup
diff --git a/.changeset/few-kings-agree.md b/.changeset/few-kings-agree.md
new file mode 100644
index 0000000000..0019611503
--- /dev/null
+++ b/.changeset/few-kings-agree.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-common': patch
+---
+
+Added support for passing false as a CSP field value, to drop it from the defaults in the backend
diff --git a/.changeset/long-birds-rush.md b/.changeset/long-birds-rush.md
new file mode 100644
index 0000000000..db4da34789
--- /dev/null
+++ b/.changeset/long-birds-rush.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Make versions:bump install new versions of dependencies that were within the specified range
diff --git a/.changeset/pink-goats-sort.md b/.changeset/pink-goats-sort.md
new file mode 100644
index 0000000000..828cccba84
--- /dev/null
+++ b/.changeset/pink-goats-sort.md
@@ -0,0 +1,6 @@
+---
+'@backstage/catalog-model': patch
+'@backstage/plugin-catalog-backend': patch
+---
+
+Start emitting all known relation types from the core entity kinds, based on their spec data.
diff --git a/.changeset/small-worms-check.md b/.changeset/small-worms-check.md
deleted file mode 100644
index e39265f8f9..0000000000
--- a/.changeset/small-worms-check.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'example-app': patch
-'@backstage/plugin-search': patch
----
-
-Using the search field in the sidebar now navigates to the search result page.
diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index c31e19e35c..a0841fe7d0 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -131,6 +131,7 @@ npm
nvm
oauth
Oauth
+oidc
Okta
Oldsberg
onboarding
diff --git a/.gitignore b/.gitignore
index 5c27601791..3334bf956d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,6 +96,7 @@ typings/
.nuxt
dist
dist-types
+dist-workspace
# Gatsby files
.cache/
diff --git a/app-config.yaml b/app-config.yaml
index 32d3470f1b..fcc54a1a35 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -189,8 +189,10 @@ scaffolder:
api:
token:
$env: AZURE_TOKEN
-
auth:
+ ### Providing an auth.session.secret will enable session support in the auth-backend
+ # session:
+ # secret: custom session secret
providers:
google:
development:
@@ -235,6 +237,20 @@ auth:
$env: AUTH_OAUTH2_AUTH_URL
tokenUrl:
$env: AUTH_OAUTH2_TOKEN_URL
+ oidc:
+ development:
+ metadataUrl:
+ $env: AUTH_OIDC_METADATA_URL
+ clientId:
+ $env: AUTH_OIDC_CLIENT_ID
+ clientSecret:
+ $env: AUTH_OIDC_CLIENT_SECRET
+ authorizationUrl:
+ $env: AUTH_OIDC_AUTH_URL
+ tokenUrl:
+ $env: AUTH_OIDC_TOKEN_URL
+ tokenSignedResponseAlg:
+ $env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG
auth0:
development:
clientId:
diff --git a/docs/assets/techdocs/architecture-basic.drawio.svg b/docs/assets/techdocs/architecture-basic.drawio.svg
new file mode 100644
index 0000000000..8e3be76f8b
--- /dev/null
+++ b/docs/assets/techdocs/architecture-basic.drawio.svg
@@ -0,0 +1,462 @@
+
diff --git a/docs/assets/techdocs/architecture-recommended.drawio.svg b/docs/assets/techdocs/architecture-recommended.drawio.svg
new file mode 100644
index 0000000000..e3af4b6b5f
--- /dev/null
+++ b/docs/assets/techdocs/architecture-recommended.drawio.svg
@@ -0,0 +1,391 @@
+
diff --git a/docs/assets/techdocs/techdocs_big_picture.png b/docs/assets/techdocs/techdocs_big_picture.png
deleted file mode 100644
index 8b8f7a2338..0000000000
Binary files a/docs/assets/techdocs/techdocs_big_picture.png and /dev/null differ
diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md
index 5d7a5b9af0..f1d426f407 100644
--- a/docs/features/software-catalog/descriptor-format.md
+++ b/docs/features/software-catalog/descriptor-format.md
@@ -43,7 +43,7 @@ software catalog API.
"kind": "Component",
"metadata": {
"annotations": {
- "backstage.io/managed-by-location": "file:/tmp/component-info.yaml",
+ "backstage.io/managed-by-location": "file:/tmp/catalog-info.yaml",
"example.com/service-discovery": "artistweb",
"circleci.com/project-slug": "github/example-org/artist-website"
},
@@ -93,6 +93,43 @@ significance and have reserved purposes and distinct shapes.
See below for details about these fields.
+## Substitutions In The Descriptor Format
+
+The descriptor format supports substitutions using `$text`, `$json`, and
+`$yaml`.
+
+Placeholders like `$json: https://example.com/entity.json` are substituted by
+the content of the referenced file. Files can be referenced from any configured
+integration similar to locations by passing an absolute URL. It's also possible
+to reference relative files like `./referenced.yaml` from the same location.
+Relative references are handled relative to the folder of the
+`catalog-info.yaml` that contains the placeholder. There are three different
+types of placeholders:
+
+- `$text`: Interprets the contents of the referenced file as plain text and
+ embeds it as a string.
+- `$json`: Interprets the contents of the referenced file as JSON and embeds the
+ parsed structure.
+- `$yaml`: Interprets the contents of the referenced file as YAML and embeds the
+ parsed structure.
+
+For example, this can be used to load the definition of an API entity from a web
+server and embed it as a string in the field `spec.definition`:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: API
+metadata:
+ name: petstore
+ description: The Petstore API
+spec:
+ type: openapi
+ lifecycle: production
+ owner: petstore@example.com
+ definition:
+ $text: https://petstore.swagger.io/v2/swagger.json
+```
+
## Common to All Kinds: The Envelope
The root envelope object has the following structure.
@@ -592,6 +629,9 @@ The current set of well-known and common values for this field is:
[OpenAPI](https://swagger.io/specification/) version 2 or version 3 spec.
- `asyncapi` - An API definition based on the
[AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) spec.
+- `graphql` - An API definition based on
+ [GraphQL schemas](https://spec.graphql.org/) for consuming
+ [GraphQL](https://graphql.org/) based APIs.
- `grpc` - An API definition based on
[Protocol Buffers](https://developers.google.com/protocol-buffers) to use with
[gRPC](https://grpc.io/).
@@ -700,6 +740,11 @@ sufficient to enter only the `metadata.name` field of that group.
### `spec.ancestors` [required]
+**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
+removed entirely from the model on Dec 6th, 2020 in the repository and will not
+be present in released packages following the next release after that. Please
+update your code to not consume this field before the removal date.
+
The recursive list of parents up the hierarchy, by stepping through parents one
by one. The list must be present, but may be empty if `parent` is not present.
The first entry in the list is equal to `parent`, and then the following ones
@@ -728,6 +773,11 @@ sufficient to enter only the `metadata.name` field of those groups.
### `spec.descendants` [required]
+**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
+removed entirely from the model on Dec 6th, 2020 in the repository and will not
+be present in released packages following the next release after that. Please
+update your code to not consume this field before the removal date.
+
The immediate and recursive child groups of this group in the hierarchy
(children, and children's children, etc.). The list must be present, but may be
empty if there are no child groups. The items are not guaranteed to be ordered
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 7d16427a3c..3627c74b9a 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -238,22 +238,9 @@ annotation, with the same value format.
### backstage.io/definition-at-location
-This annotation allowed to load the API definition from another location. Now
-placeholders can be used instead:
-
-```
-apiVersion: backstage.io/v1alpha1
-kind: API
-metadata:
- name: petstore
- description: The Petstore API
-spec:
- type: openapi
- lifecycle: production
- owner: petstore@example.com
- definition:
- $text: https://petstore.swagger.io/v2/swagger.json
-```
+This annotation allowed to load the API definition from another location. Use
+[substitution](./descriptor-format.md#substitutions-in-the-descriptor-format)
+instead.
## Links
diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md
index 75a75003f7..e2b69d6767 100644
--- a/docs/features/software-templates/adding-templates.md
+++ b/docs/features/software-templates/adding-templates.md
@@ -55,6 +55,10 @@ contains more information about the required fields.
Once we have a `template.yaml` ready, we can then add it to the service catalog
for use by the scaffolder.
+_NOTE_: When the `publish` step is completed, it is currently assumed by the
+scaffolder that the final repository should contain a `catalog-info.yaml` in
+order to register this with the Catalog in Backstage.
+
Currently the catalog supports loading definitions from GitHub + Local Files. To
load from other places, not only will there need to be another preparer, but the
support to load the location will also need to be added to the Catalog.
diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md
index 926ec238e1..01497b31bc 100644
--- a/docs/features/techdocs/architecture.md
+++ b/docs/features/techdocs/architecture.md
@@ -1,7 +1,153 @@
---
id: architecture
-title: Architecture
-description: Documentation on Architecture
+title: TechDocs Architecture
+description: Documentation on TechDocs Architecture
---
-
+## Basic (out-of-the-box)
+
+When you deploy Backstage (with TechDocs enabled by default), you get a basic
+out-of-the box experience.
+
+
+
+> Note: See below for our recommended deployment architecture which takes care
+> of stability, scalability and speed.
+
+When you open a TechDocs site in Backstage, the
+[TechDocs Reader](./concepts.md#techdocs-reader) makes a request to
+`techdocs-backend` with the entity ID and the path of the current page you are
+looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to
+render on the page in TechDocs/Backstage.
+
+The static files consist of HTML, CSS and Images generated by MkDocs. We remove
+all the Javascript before adding them to Backstage for security reasons. And
+there are some additional techdocs metadata JSON files that TechDocs needs to
+render a site.
+
+The TechDocs Reader then applies a list of "Transformers" (see
+[Concepts](./concepts.md)) which modify the generated static HTML files for a
+number of use cases e.g. Remove certain headers, filter out some HTML tags, etc.
+
+Currently, we use the Backstage server's (or techdocs-backend's) local file
+system to store the generated files. Publishing to an external storage system
+(AWS S3, GCS, etc.) is also possible, but has not been implemented yet.
+
+A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch
+files from its source repository for docs site generation. 1. By using Git
+and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is
+heavily in progress. Please reach out to us on Discord in the #docs-like-code
+channel to talk about it.
+
+## Recommended deployment
+
+This is how we recommend deploying TechDocs in production environment.
+
+
+
+The key difference in the recommended deployment approach is where the docs are
+built.
+
+We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We
+recommend using a CI/CD pipeline with the repository that has a dedicated
+step/job to build docs for TechDocs. The generated static files are then stored
+in a cloud storage solution of your choice.
+[Track progress here](https://github.com/backstage/backstage/issues/3096).
+
+Similar to how it is done in the Basic setup, the TechDocs Reader requests
+`techdocs-backend` plugin for the docs site. `techdocs-backend` then requests
+your configured storage solution for the necessary files and returns them to
+TechDocs Reader.
+
+We will provide instructions, scripts and/or templates (e.g. GitHub actions) to
+build docs in your CI/CD system.
+[Track progress here.](https://github.com/backstage/backstage/issues/3400) You
+will be able to use `techdocs-cli` to build docs and publish the generated docs
+site files to your cloud storage system.
+
+Note about caching: We have noticed internally that some storage providers can
+be quite slow, which is why we are recommending a cache that sits between the
+TechDocs Reader and the Storage.
+
+_Feel free to suggest better ideas to us in #docs-like-code channel in Discord
+or via a GitHub issue._
+
+### Security consideration
+
+Our biggest security concern is managing the access to the docs in the cloud
+storage. We also want to have only one security solution for all different types
+of storage (GCS, AWS, custom SFTP server, etc.) Restricting access to the
+storage and only allowing `techdocs-backend` to fetch files is a good way to
+achieve this.
+
+This would also allow us to use the access control management Backstage when
+that is ready.
+[Track progress here.](https://github.com/backstage/backstage/issues/3218)
+
+In theory, you can directly enable TechDocs Reader to read from your storage.
+But, you will have to think about how to do it without the docs being public and
+how access to user groups is managed.
+
+For cloud storage access tokens, `techdocs-backend` only needs a token with Read
+permissions. But in your CI/CD system, there needs to be a token with Write
+permissions to publish the generated docs site files.
+
+## FAQs
+
+**Q: Why do you have separate "basic" and "recommended" deployment approaches?**
+
+A: The basic or out-of-the-box setup is what you get when you create a new app
+or do a git clone of the Backstage repository. We want the first experience to
+_just work magically_ so that you can have your first experience with TechDocs
+which is smooth. However, if you decide to deploy Backstage/TechDocs for
+production use, the basic setup would work but there are going to be downsides
+as you scale with the number of documentation sites and sizes of them. So you
+would want to make sure the deployment is as stable as possible. Hence there is
+a recommended approach. There can be even more deployment approaches to TechDocs
+and we welcome such "Alternative" ideas from the community.
+
+**Q: Why don't you recommend techdocs-backend local filesystem to serve static
+files?**
+
+A: It would make scaling a Backstage instance harder. Think about the case where
+we have distributed Backstage deployments. Using a separate file storage system
+for TechDocs makes it easier to do some operations like delete a docs site and
+wipe its contents.
+
+**Q: Why aren't docs built on the fly i.e. when users visits a page, generate
+docs site in real-time?**
+
+A: Generating the content from Markdown on the fly is not optimal (although that
+is how the basic out-of-the-box setup is implemented). Storage solutions act as
+a cache for the generated static content. TechDocs is also currently built on
+MkDocs which does not allow us to build docs per-page, so we would have to build
+all docs for a entity on every request.
+
+# Future work
+
+_Ideas here are far fetched and not in the project's milestone for near future
+(~6 months)._
+
+We currently depend on MkDocs to parse doc sites written in Markdown. And we
+store the generated static assets and re-use it later to render in Backstage. A
+better (futuristic) approach will be to directly parse whatever type of source
+files you have in your docs repository and directly render in Backstage in
+real-time.
+
+# Features status
+
+Status of all the features mentioned above.
+
+**In place ✅**
+
+- Basic setup with techdocs-backend file server as storage.
+
+**Work in progress 🚧**
+
+- Basic setup with cloud storage solution.
+
+**Not implemented yet ❌**
+
+- `techdocs-cli` is able to generate docs in CI/CD environment.
+- `techdocs-cli` is able to publish docs site to any storage.
+- `techdocs-backend` integration with Backstage access control management.
diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md
index 552b6c3ec2..6aef4c48c1 100644
--- a/docs/getting-started/create-an-app.md
+++ b/docs/getting-started/create-an-app.md
@@ -14,7 +14,7 @@ need to run Backstage in your own environment.
To create a Backstage app, you will need to have
[Node.js](https://nodejs.org/en/download/) Active LTS Release installed
-(currently v12).
+(currently v14).
Backstage provides a utility for creating new apps. It guides you through the
initial setup of selecting the name of the app and a database for the backend.
diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md
index a30a918fd0..6de9d234e3 100644
--- a/docs/getting-started/running-backstage-locally.md
+++ b/docs/getting-started/running-backstage-locally.md
@@ -8,19 +8,19 @@ description: Documentation on How to run Backstage Locally
- Node.js
-First make sure you are using Node.js with an Active LTS Release, currently v12.
+First make sure you are using Node.js with an Active LTS Release, currently v14.
This is made easy with a version manager such as
[nvm](https://github.com/nvm-sh/nvm) which allows for version switching.
```bash
# Installing a new version
-nvm install 12
-> Downloading and installing node v12.18.3...
-> Now using node v12.18.3 (npm v6.14.6)
+nvm install 14
+> Downloading and installing node v14.15.1...
+> Now using node v14.15.1 (npm v6.14.8)
# Checking your version
node --version
-> v12.18.3
+> v14.15.1
```
- Yarn
diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml
new file mode 100644
index 0000000000..579243d11c
--- /dev/null
+++ b/microsite/data/plugins/jira.yaml
@@ -0,0 +1,9 @@
+---
+title: Jira
+author: roadie.io
+authorUrl: https://roadie.io
+category: Project Management
+description: View Jira summary for your projects in Backstage.
+documentation: https://roadie.io/backstage/plugins/jira
+iconUrl: https://roadie.io/images/logos/jira.png
+npmPackageName: '@roadiehq/backstage-plugin-jira'
diff --git a/mkdocs.yml b/mkdocs.yml
index 8a4a78ddf1..1086259710 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,6 +1,9 @@
site_name: 'Backstage'
site_description: 'Main documentation for Backstage features and platform APIs'
+plugins:
+ - techdocs-core
+
nav:
- Overview:
- What is Backstage?: 'overview/what-is-backstage.md'
@@ -111,6 +114,3 @@ nav:
- 'support/support.md'
- 'support/project-structure.md'
- FAQ: FAQ.md
-
-plugins:
- - techdocs-core
diff --git a/package.json b/package.json
index 4140886bc3..3be94c19cf 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,8 @@
]
},
"resolutions": {
- "**/@roadiehq/backstage-plugin-*/@backstage/core": "*"
+ "**/@roadiehq/**/@backstage/core": "*",
+ "**/@roadiehq/**/@backstage/catalog-model": "*"
},
"version": "1.0.0",
"devDependencies": {
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index ab00924a26..f70cd198b4 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,42 @@
# example-app
+## 0.2.3
+
+### Patch Changes
+
+- 475fc0aaa: Using the search field in the sidebar now navigates to the search result page.
+- Updated dependencies [475fc0aaa]
+- Updated dependencies [1166fcc36]
+- Updated dependencies [29a0ccab2]
+- Updated dependencies [8e6728e25]
+- Updated dependencies [c93a14b49]
+- Updated dependencies [ef2831dde]
+- Updated dependencies [2a71f4bab]
+- Updated dependencies [1185919f3]
+- Updated dependencies [a8de7f554]
+- Updated dependencies [faf311c26]
+- Updated dependencies [31d8b6979]
+- Updated dependencies [991345969]
+- Updated dependencies [475fc0aaa]
+ - @backstage/core@0.3.2
+ - @backstage/catalog-model@0.3.0
+ - @backstage/plugin-kubernetes@0.3.0
+ - @backstage/cli@0.3.1
+ - @backstage/plugin-cost-insights@0.4.1
+ - @backstage/plugin-scaffolder@0.3.1
+ - @backstage/plugin-register-component@0.2.2
+ - @backstage/plugin-circleci@0.2.2
+ - @backstage/plugin-search@0.2.1
+ - @backstage/plugin-api-docs@0.2.2
+ - @backstage/plugin-catalog@0.2.3
+ - @backstage/plugin-cloudbuild@0.2.2
+ - @backstage/plugin-github-actions@0.2.2
+ - @backstage/plugin-jenkins@0.3.1
+ - @backstage/plugin-lighthouse@0.2.3
+ - @backstage/plugin-rollbar@0.2.3
+ - @backstage/plugin-sentry@0.2.3
+ - @backstage/plugin-techdocs@0.2.3
+
## 0.2.2
### Patch Changes
diff --git a/packages/app/package.json b/packages/app/package.json
index 52906bb65a..9e54005cd3 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,34 +1,33 @@
{
"name": "example-app",
- "version": "0.2.1",
+ "version": "0.2.3",
"private": true,
"bundled": true,
"dependencies": {
- "@backstage/catalog-model": "^0.2.0",
- "@backstage/cli": "^0.3.0",
- "@backstage/core": "^0.3.1",
- "@backstage/plugin-api-docs": "^0.2.1",
- "@backstage/plugin-catalog": "^0.2.2",
- "@backstage/plugin-catalog-import": "^0.2.0",
- "@backstage/plugin-circleci": "^0.2.1",
- "@backstage/plugin-cloudbuild": "^0.2.1",
- "@backstage/plugin-cost-insights": "^0.4.0",
+ "@backstage/catalog-model": "^0.3.0",
+ "@backstage/cli": "^0.3.1",
+ "@backstage/core": "^0.3.2",
+ "@backstage/plugin-api-docs": "^0.2.2",
+ "@backstage/plugin-catalog": "^0.2.3",
+ "@backstage/plugin-circleci": "^0.2.2",
+ "@backstage/plugin-cloudbuild": "^0.2.2",
+ "@backstage/plugin-cost-insights": "^0.4.1",
"@backstage/plugin-explore": "^0.2.1",
"@backstage/plugin-gcp-projects": "^0.2.1",
- "@backstage/plugin-github-actions": "^0.2.1",
+ "@backstage/plugin-github-actions": "^0.2.2",
"@backstage/plugin-gitops-profiles": "^0.2.1",
"@backstage/plugin-graphiql": "^0.2.1",
- "@backstage/plugin-jenkins": "^0.3.0",
- "@backstage/plugin-kubernetes": "^0.2.1",
- "@backstage/plugin-lighthouse": "^0.2.2",
+ "@backstage/plugin-jenkins": "^0.3.1",
+ "@backstage/plugin-kubernetes": "^0.3.0",
+ "@backstage/plugin-lighthouse": "^0.2.3",
"@backstage/plugin-newrelic": "^0.2.1",
- "@backstage/plugin-register-component": "^0.2.1",
- "@backstage/plugin-rollbar": "^0.2.2",
- "@backstage/plugin-scaffolder": "^0.3.0",
- "@backstage/plugin-sentry": "^0.2.2",
- "@backstage/plugin-search": "^0.2.0",
+ "@backstage/plugin-register-component": "^0.2.2",
+ "@backstage/plugin-rollbar": "^0.2.3",
+ "@backstage/plugin-scaffolder": "^0.3.1",
+ "@backstage/plugin-sentry": "^0.2.3",
+ "@backstage/plugin-search": "^0.2.1",
"@backstage/plugin-tech-radar": "^0.3.0",
- "@backstage/plugin-techdocs": "^0.2.2",
+ "@backstage/plugin-techdocs": "^0.2.3",
"@backstage/plugin-user-settings": "^0.2.2",
"@backstage/plugin-welcome": "^0.2.1",
"@backstage/test-utils": "^0.1.3",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index d826bfa25e..068019c622 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -13,63 +13,66 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { ApiEntity, Entity } from '@backstage/catalog-model';
+import { EmptyState } from '@backstage/core';
import {
- isPluginApplicableToEntity as isTravisCIAvailable,
- RecentTravisCIBuildsWidget,
- Router as TravisCIRouter,
-} from '@roadiehq/backstage-plugin-travis-ci';
+ ApiDefinitionCard,
+ Router as ApiDocsRouter,
+} from '@backstage/plugin-api-docs';
+import {
+ AboutCard,
+ EntityPageLayout,
+ useEntity,
+} from '@backstage/plugin-catalog';
+import {
+ isPluginApplicableToEntity as isCircleCIAvailable,
+ Router as CircleCIRouter,
+} from '@backstage/plugin-circleci';
+import {
+ isPluginApplicableToEntity as isCloudbuildAvailable,
+ Router as CloudbuildRouter,
+} from '@backstage/plugin-cloudbuild';
import {
isPluginApplicableToEntity as isGitHubActionsAvailable,
RecentWorkflowRunsCard,
Router as GitHubActionsRouter,
} from '@backstage/plugin-github-actions';
import {
- Router as CloudbuildRouter,
- isPluginApplicableToEntity as isCloudbuildAvailable,
-} from '@backstage/plugin-cloudbuild';
-import {
- Router as JenkinsRouter,
isPluginApplicableToEntity as isJenkinsAvailable,
LatestRunCard as JenkinsLatestRunCard,
+ Router as JenkinsRouter,
} from '@backstage/plugin-jenkins';
-import {
- isPluginApplicableToEntity as isCircleCIAvailable,
- Router as CircleCIRouter,
-} from '@backstage/plugin-circleci';
-import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
-import { Router as SentryRouter } from '@backstage/plugin-sentry';
-import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
-import {
- Router as GitHubInsightsRouter,
- isPluginApplicableToEntity as isGitHubAvailable,
- ReadMeCard,
- LanguagesCard,
- ReleasesCard,
-} from '@roadiehq/backstage-plugin-github-insights';
-import React, { ReactNode } from 'react';
-import {
- AboutCard,
- EntityPageLayout,
- useEntity,
-} from '@backstage/plugin-catalog';
-import { Entity } from '@backstage/catalog-model';
-import { Button, Grid } from '@material-ui/core';
-import { EmptyState } from '@backstage/core';
import {
EmbeddedRouter as LighthouseRouter,
- LastLighthouseAuditCard,
isPluginApplicableToEntity as isLighthouseAvailable,
-} from '@backstage/plugin-lighthouse/';
+ LastLighthouseAuditCard,
+} from '@backstage/plugin-lighthouse';
+import { Router as SentryRouter } from '@backstage/plugin-sentry';
+import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
+import { Button, Grid } from '@material-ui/core';
+import {
+ isPluginApplicableToEntity as isBuildkiteAvailable,
+ Router as BuildkiteRouter,
+} from '@roadiehq/backstage-plugin-buildkite';
+import {
+ isPluginApplicableToEntity as isGitHubAvailable,
+ LanguagesCard,
+ ReadMeCard,
+ ReleasesCard,
+ Router as GitHubInsightsRouter,
+} from '@roadiehq/backstage-plugin-github-insights';
import {
- Router as PullRequestsRouter,
isPluginApplicableToEntity as isPullRequestsAvailable,
PullRequestsStatsCard,
+ Router as PullRequestsRouter,
} from '@roadiehq/backstage-plugin-github-pull-requests';
import {
- Router as BuildkiteRouter,
- isPluginApplicableToEntity as isBuildkiteAvailable,
-} from '@roadiehq/backstage-plugin-buildkite';
+ isPluginApplicableToEntity as isTravisCIAvailable,
+ RecentTravisCIBuildsWidget,
+ Router as TravisCIRouter,
+} from '@roadiehq/backstage-plugin-travis-ci';
+import React, { ReactNode } from 'react';
export const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
@@ -79,10 +82,10 @@ export const CICDSwitcher = ({ entity }: { entity: Entity }) => {
return ;
case isBuildkiteAvailable(entity):
return ;
- case isGitHubActionsAvailable(entity):
- return ;
case isCircleCIAvailable(entity):
return ;
+ case isGitHubActionsAvailable(entity):
+ return ;
case isCloudbuildAvailable(entity):
return ;
case isTravisCIAvailable(entity):
@@ -134,7 +137,7 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
);
};
-const OverviewContent = ({ entity }: { entity: Entity }) => (
+const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
@@ -169,7 +172,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
}
+ element={}
/>
(
}
+ element={}
/>
(
/>
);
+
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
}
+ element={}
/>
(
);
-export const EntityPage = () => {
- const { entity } = useEntity();
+export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
switch (entity?.spec?.type) {
case 'service':
return ;
@@ -279,3 +282,47 @@ export const EntityPage = () => {
return ;
}
};
+
+const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
+
+
+
+
+
+);
+
+const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
+
+
+
+
+
+);
+
+const ApiEntityPage = ({ entity }: { entity: Entity }) => (
+
+ }
+ />
+ }
+ />
+
+);
+
+export const EntityPage = () => {
+ const { entity } = useEntity();
+
+ switch (entity?.kind?.toLowerCase()) {
+ case 'component':
+ return ;
+ case 'api':
+ return ;
+ default:
+ return ;
+ }
+};
diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts
index 06ff6b07d3..01828b1405 100644
--- a/packages/app/src/identityProviders.ts
+++ b/packages/app/src/identityProviders.ts
@@ -22,9 +22,16 @@ import {
samlAuthApiRef,
microsoftAuthApiRef,
oneloginAuthApiRef,
+ oidcAuthApiRef,
} from '@backstage/core';
export const providers = [
+ {
+ id: 'oidc-auth-provider',
+ title: 'Oidc',
+ message: 'Sign In using OpenId Connect',
+ apiRef: oidcAuthApiRef,
+ },
{
id: 'google-auth-provider',
title: 'Google',
diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md
index 4822032c7f..793a554905 100644
--- a/packages/backend-common/CHANGELOG.md
+++ b/packages/backend-common/CHANGELOG.md
@@ -1,5 +1,12 @@
# @backstage/backend-common
+## 0.3.1
+
+### Patch Changes
+
+- bff3305aa: Added readTree support to AzureUrlReader
+- b47dce06f: Make integration host and url configurations visible in the frontend
+
## 0.3.0
### Minor Changes
diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts
index 7ebcd01e8c..c4ec84ab62 100644
--- a/packages/backend-common/config.d.ts
+++ b/packages/backend-common/config.d.ts
@@ -79,15 +79,25 @@ export interface Config {
optionsSuccessStatus?: number;
};
- /** */
- csp?: object;
+ /**
+ * Content Security Policy options.
+ *
+ * The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The
+ * values are on the format that the helmet library expects them, as an
+ * array of strings. There is also the special value false, which means to
+ * remove the default value that Backstage puts in place for that policy.
+ */
+ csp?: { [policyId: string]: string[] | false };
};
/** Configuration for integrations towards various external repository provider systems */
integrations?: {
/** Integration configuration for Azure */
azure?: Array<{
- /** The hostname of the given Azure instance */
+ /**
+ * The hostname of the given Azure instance
+ * @visibility frontend
+ */
host: string;
/**
* Token used to authenticate requests.
@@ -98,14 +108,20 @@ export interface Config {
/** Integration configuration for BitBucket */
bitbucket?: Array<{
- /** The hostname of the given Bitbucket instance */
+ /**
+ * The hostname of the given Bitbucket instance
+ * @visibility frontend
+ */
host: string;
/**
* Token used to authenticate requests.
* @visibility secret
*/
token?: string;
- /** The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 */
+ /**
+ * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0
+ * @visibility frontend
+ */
apiBaseUrl?: string;
/**
* The username to use for authenticated requests.
@@ -121,22 +137,34 @@ export interface Config {
/** Integration configuration for GitHub */
github?: Array<{
- /** The hostname of the given GitHub instance */
+ /**
+ * The hostname of the given GitHub instance
+ * @visibility frontend
+ */
host: string;
/**
* Token used to authenticate requests.
* @visibility secret
*/
token?: string;
- /** The base url for the GitHub API, for example https://api.github.com */
+ /**
+ * The base url for the GitHub API, for example https://api.github.com
+ * @visibility frontend
+ */
apiBaseUrl?: string;
- /** The base url for GitHub raw resources, for example https://raw.githubusercontent.com */
+ /**
+ * The base url for GitHub raw resources, for example https://raw.githubusercontent.com
+ * @visibility frontend
+ */
rawBaseUrl?: string;
}>;
/** Integration configuration for GitLab */
gitlab?: Array<{
- /** The hostname of the given GitLab instance */
+ /**
+ * The hostname of the given GitLab instance
+ * @visibility frontend
+ */
host: string;
/**
* Token used to authenticate requests.
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index d4f0052d28..731e2679cb 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
- "version": "0.3.0",
+ "version": "0.3.1",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -33,7 +33,6 @@
"@backstage/config": "^0.1.1",
"@backstage/config-loader": "^0.3.0",
"@backstage/integration": "^0.1.1",
- "@backstage/test-utils": "^0.1.3",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"archiver": "^5.0.2",
@@ -68,7 +67,8 @@
}
},
"devDependencies": {
- "@backstage/cli": "^0.3.0",
+ "@backstage/cli": "^0.3.1",
+ "@backstage/test-utils": "^0.1.3",
"@types/archiver": "^3.1.1",
"@types/compression": "^1.7.0",
"@types/concat-stream": "^1.6.0",
diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts
new file mode 100644
index 0000000000..cffb0a88de
--- /dev/null
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { applyCspDirectives } from './ServiceBuilderImpl';
+
+describe('ServiceBuilderImpl', () => {
+ describe('applyCspDirectives', () => {
+ it('copies actual values', () => {
+ const result = applyCspDirectives({ key: ['value'] });
+ expect(result).toEqual(
+ expect.objectContaining({
+ 'default-src': ["'self'"],
+ key: ['value'],
+ }),
+ );
+ });
+
+ it('removes false value keys', () => {
+ const result = applyCspDirectives({ 'upgrade-insecure-requests': false });
+ expect(result!['upgrade-insecure-requests']).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
index 710de6f1d7..d8b9ffa2d9 100644
--- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
@@ -18,7 +18,7 @@ import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
-import helmet from 'helmet';
+import helmet, { HelmetOptions } from 'helmet';
import * as http from 'http';
import stoppable from 'stoppable';
import { Logger } from 'winston';
@@ -56,7 +56,7 @@ const DEFAULT_CSP = {
'script-src': ["'self'"],
'script-src-attr': ["'none'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
- 'upgrade-insecure-requests': [],
+ 'upgrade-insecure-requests': [] as string[],
};
export class ServiceBuilderImpl implements ServiceBuilder {
@@ -64,7 +64,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
private host: string | undefined;
private logger: Logger | undefined;
private corsOptions: cors.CorsOptions | undefined;
- private cspOptions: CspOptions | undefined;
+ private cspOptions: Record | undefined;
private httpsSettings: HttpsSettings | undefined;
private enableMetrics: boolean = true;
private routers: [string, Router][];
@@ -154,20 +154,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
host,
logger,
corsOptions,
- cspOptions,
httpsSettings,
+ helmetOptions,
} = this.getOptions();
- app.use(
- helmet({
- contentSecurityPolicy: {
- directives: {
- ...DEFAULT_CSP,
- ...cspOptions,
- },
- },
- }),
- );
+ app.use(helmet(helmetOptions));
if (corsOptions) {
app.use(cors(corsOptions));
}
@@ -214,16 +205,38 @@ export class ServiceBuilderImpl implements ServiceBuilder {
host: string;
logger: Logger;
corsOptions?: cors.CorsOptions;
- cspOptions?: CspOptions;
httpsSettings?: HttpsSettings;
+ helmetOptions: HelmetOptions;
} {
return {
port: this.port ?? DEFAULT_PORT,
host: this.host ?? DEFAULT_HOST,
logger: this.logger ?? getRootLogger(),
corsOptions: this.corsOptions,
- cspOptions: this.cspOptions,
httpsSettings: this.httpsSettings,
+ helmetOptions: {
+ contentSecurityPolicy: {
+ directives: applyCspDirectives(this.cspOptions),
+ },
+ },
};
}
}
+
+export function applyCspDirectives(
+ directives: Record | undefined,
+): CspOptions | undefined {
+ const result: CspOptions = { ...DEFAULT_CSP };
+
+ if (directives) {
+ for (const [key, value] of Object.entries(directives)) {
+ if (value === false) {
+ delete result[key];
+ } else {
+ result[key] = value;
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts
new file mode 100644
index 0000000000..8a35f147ca
--- /dev/null
+++ b/packages/backend-common/src/service/lib/config.test.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { ConfigReader } from '@backstage/config';
+import { readCspOptions } from './config';
+
+describe('config', () => {
+ describe('readCspOptions', () => {
+ it('reads valid values', () => {
+ const config = ConfigReader.fromConfigs([
+ { context: '', data: { csp: { key: ['value'] } } },
+ ]);
+ expect(readCspOptions(config)).toEqual(
+ expect.objectContaining({
+ key: ['value'],
+ }),
+ );
+ });
+
+ it('accepts false', () => {
+ const config = ConfigReader.fromConfigs([
+ { context: '', data: { csp: { key: false } } },
+ ]);
+ expect(readCspOptions(config)).toEqual(
+ expect.objectContaining({
+ key: false,
+ }),
+ );
+ });
+
+ it('rejects invalid value types', () => {
+ const config = ConfigReader.fromConfigs([
+ { context: '', data: { csp: { key: [4] } } },
+ ]);
+ expect(() => readCspOptions(config)).toThrow(/wanted string-array/);
+ });
+ });
+});
diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts
index bfd966b499..541170b704 100644
--- a/packages/backend-common/src/service/lib/config.ts
+++ b/packages/backend-common/src/service/lib/config.ts
@@ -134,24 +134,33 @@ export function readCorsOptions(config: Config): CorsOptions | undefined {
* Attempts to read a CSP options object from the root of a config object.
*
* @param config The root of a backend config object
- * @returns A CSP options object, or undefined if not specified
+ * @returns A CSP options object, or undefined if not specified. Values can be
+ * false as well, which means to remove the default behavior for that
+ * key.
*
* @example
* ```yaml
* backend:
* csp:
* connect-src: ["'self'", 'http:', 'https:']
+ * upgrade-insecure-requests: false
* ```
*/
-export function readCspOptions(config: Config): CspOptions | undefined {
+export function readCspOptions(
+ config: Config,
+): Record | undefined {
const cc = config.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
- const result: CspOptions = {};
+ const result: Record = {};
for (const key of cc.keys()) {
- result[key] = cc.getStringArray(key);
+ if (cc.get(key) === false) {
+ result[key] = false;
+ } else {
+ result[key] = cc.getStringArray(key);
+ }
}
return result;
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index a8bc1fef7d..bc2a08e983 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,26 @@
# example-backend
+## 0.2.3
+
+### Patch Changes
+
+- Updated dependencies [1166fcc36]
+- Updated dependencies [bff3305aa]
+- Updated dependencies [0c2121240]
+- Updated dependencies [ef2831dde]
+- Updated dependencies [1185919f3]
+- Updated dependencies [475fc0aaa]
+- Updated dependencies [b47dce06f]
+- Updated dependencies [5a1d8dca3]
+ - @backstage/catalog-model@0.3.0
+ - @backstage/plugin-kubernetes-backend@0.2.0
+ - @backstage/backend-common@0.3.1
+ - @backstage/plugin-catalog-backend@0.2.2
+ - @backstage/plugin-scaffolder-backend@0.3.2
+ - example-app@0.2.3
+ - @backstage/plugin-auth-backend@0.2.3
+ - @backstage/plugin-techdocs-backend@0.2.2
+
## 0.2.2
### Patch Changes
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 410aca1087..b9daf224f0 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.2.1",
+ "version": "0.2.3",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
@@ -18,24 +18,24 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.3.0",
- "@backstage/catalog-model": "^0.2.0",
+ "@backstage/backend-common": "^0.3.1",
+ "@backstage/catalog-model": "^0.3.0",
"@backstage/config": "^0.1.1",
"@backstage/plugin-app-backend": "^0.3.0",
- "@backstage/plugin-auth-backend": "^0.2.2",
+ "@backstage/plugin-auth-backend": "^0.2.3",
"@backstage/plugin-catalog-backend": "^0.3.0",
"@backstage/plugin-graphql-backend": "^0.1.3",
- "@backstage/plugin-kubernetes-backend": "^0.1.3",
+ "@backstage/plugin-kubernetes-backend": "^0.2.0",
"@backstage/plugin-proxy-backend": "^0.2.1",
"@backstage/plugin-rollbar-backend": "^0.1.3",
- "@backstage/plugin-scaffolder-backend": "^0.3.1",
+ "@backstage/plugin-scaffolder-backend": "^0.3.2",
"@backstage/plugin-sentry-backend": "^0.1.3",
- "@backstage/plugin-techdocs-backend": "^0.2.1",
+ "@backstage/plugin-techdocs-backend": "^0.2.2",
"@gitbeaker/node": "^25.2.0",
"@octokit/rest": "^18.0.0",
"azure-devops-node-api": "^10.1.1",
"dockerode": "^3.2.0",
- "example-app": "^0.2.1",
+ "example-app": "^0.2.3",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.6",
@@ -45,7 +45,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.3.0",
+ "@backstage/cli": "^0.3.1",
"@types/dockerode": "^2.5.32",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md
index 574cd301bf..3454a6c8e0 100644
--- a/packages/catalog-client/CHANGELOG.md
+++ b/packages/catalog-client/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/catalog-client
+## 0.3.1
+
+### Patch Changes
+
+- Updated dependencies [1166fcc36]
+- Updated dependencies [1185919f3]
+ - @backstage/catalog-model@0.3.0
+
## 0.3.0
### Minor Changes
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index 2482f9579d..4509b7d547 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-client",
- "version": "0.3.0",
+ "version": "0.3.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,12 +20,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/catalog-model": "^0.2.0",
+ "@backstage/catalog-model": "^0.3.0",
"@backstage/config": "^0.1.1",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
- "@backstage/cli": "^0.3.0",
+ "@backstage/cli": "^0.3.1",
"@types/jest": "^26.0.7",
"msw": "^0.21.2"
},
diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md
index 051aa82c80..10bf540b46 100644
--- a/packages/catalog-model/CHANGELOG.md
+++ b/packages/catalog-model/CHANGELOG.md
@@ -1,5 +1,23 @@
# @backstage/catalog-model
+## 0.3.0
+
+### Minor Changes
+
+- 1166fcc36: add kubernetes selector to component model
+
+### Patch Changes
+
+- 1185919f3: Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details.
+
+ Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields.
+
+ The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation.
+
+ After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either.
+
+ If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code.
+
## 0.2.0
### Minor Changes
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index e712e66f58..7956454ca9 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
- "version": "0.2.0",
+ "version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,7 +29,7 @@
"yup": "^0.29.3"
},
"devDependencies": {
- "@backstage/cli": "^0.3.0",
+ "@backstage/cli": "^0.3.1",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts
index 2cacfce53a..9f6f175d8d 100644
--- a/packages/catalog-model/src/entity/ref.ts
+++ b/packages/catalog-model/src/entity/ref.ts
@@ -84,6 +84,14 @@ export function parseEntityName(
* @param context The context of defaults that the parsing happens within
* @returns The compound form of the reference
*/
+export function parseEntityRef(
+ ref: EntityRef,
+ context?: { defaultKind: string; defaultNamespace: string },
+): {
+ kind: string;
+ namespace: string;
+ name: string;
+};
export function parseEntityRef(
ref: EntityRef,
context?: { defaultKind: string },
@@ -100,14 +108,6 @@ export function parseEntityRef(
namespace: string;
name: string;
};
-export function parseEntityRef(
- ref: EntityRef,
- context?: { defaultKind: string; defaultNamespace: string },
-): {
- kind: string;
- namespace: string;
- name: string;
-};
export function parseEntityRef(
ref: EntityRef,
context: EntityRefContext = {},
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index 9d46dea03f..0e1e369e44 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -30,6 +30,15 @@ const schema = yup.object>({
lifecycle: yup.string().required().min(1),
owner: yup.string().required().min(1),
implementsApis: yup.array(yup.string().required()).notRequired(),
+ kubernetes: yup
+ .object({
+ selector: yup
+ .object({
+ matchLabels: yup.object().required(),
+ })
+ .required(),
+ })
+ .notRequired(),
})
.required(),
});
@@ -42,6 +51,13 @@ export interface ComponentEntityV1alpha1 extends Entity {
lifecycle: string;
owner: string;
implementsApis?: string[];
+ kubernetes?: {
+ selector: {
+ matchLabels: {
+ [key: string]: string;
+ };
+ };
+ };
};
}
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index f6345741f7..7be432ad1f 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -57,8 +57,22 @@ export interface GroupEntityV1alpha1 extends Entity {
spec: {
type: string;
parent?: string;
+ /**
+ * @deprecated This field will disappear on Dec 6th, 2020. Please remove
+ * any consuming code. Producers can stop producing this field
+ * before that date, as long as the catalog backend uses the
+ * BuiltinKindsEntityProcessor which inserts the fields in the
+ * mean time.
+ */
ancestors: string[];
children: string[];
+ /**
+ * @deprecated This field will disappear on Dec 6th, 2020. Please remove
+ * any consuming code. Producers can stop producing this field
+ * before that date, as long as the catalog backend uses the
+ * BuiltinKindsEntityProcessor which inserts the fields in the
+ * mean time.
+ */
descendants: string[];
};
}
diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts
index 846313ec8c..3d5d629b9e 100644
--- a/packages/catalog-model/src/kinds/relations.ts
+++ b/packages/catalog-model/src/kinds/relations.ts
@@ -33,7 +33,9 @@ export const RELATION_OWNER_OF = 'ownerOf';
* A relation with an API entity, typically from a component or system
*/
export const RELATION_CONSUMES_API = 'consumesApi';
+export const RELATION_API_CONSUMED_BY = 'apiConsumedBy';
export const RELATION_PROVIDES_API = 'providesApi';
+export const RELATION_API_PROVIDED_BY = 'apiProvidedBy';
/**
* A relation denoting a dependency on another entity.
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index a5bcc6bcfd..d198444c86 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/cli
+## 0.3.1
+
+### Patch Changes
+
+- 29a0ccab2: The CLI now detects and transforms linked packages. You can link in external packages by adding them to both the `lerna.json` and `package.json` workspace paths.
+- faf311c26: New lint rule to disallow assertions and promote `as` assertions. - @typescript-eslint/consistent-type-assertions
+- 31d8b6979: Add experimental backend:bundle command
+- 991345969: Add new `versions:check` and `versions:bump` commands to simplify version management and avoid conflicts
+
## 0.3.0
### Minor Changes
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 9b1880c1db..e2a807e42e 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -58,6 +58,7 @@ module.exports = {
],
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': 'error',
+ '@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{
diff --git a/packages/cli/package.json b/packages/cli/package.json
index ec3aaacdee..034c8a3de5 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
- "version": "0.3.0",
+ "version": "0.3.1",
"private": false,
"publishConfig": {
"access": "public"
@@ -51,6 +51,7 @@
"@types/webpack-node-externals": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^v3.10.1",
"@typescript-eslint/parser": "^v3.10.1",
+ "@yarnpkg/lockfile": "^1.1.0",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
@@ -92,6 +93,7 @@
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.27.3",
"rollup-pluginutils": "^2.8.2",
+ "semver": "^7.3.2",
"start-server-webpack-plugin": "^2.2.5",
"style-loader": "^1.2.1",
"sucrase": "^3.16.0",
@@ -109,9 +111,9 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/backend-common": "^0.3.0",
+ "@backstage/backend-common": "^0.3.1",
"@backstage/config": "^0.1.1",
- "@backstage/core": "^0.3.1",
+ "@backstage/core": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@backstage/theme": "^0.2.1",
@@ -131,6 +133,7 @@
"@types/tar": "^4.0.3",
"@types/webpack": "^4.41.7",
"@types/webpack-dev-server": "^3.11.0",
+ "@types/yarnpkg__lockfile": "^1.1.4",
"del": "^5.1.0",
"mock-fs": "^4.13.0",
"nodemon": "^2.0.2",
diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts
new file mode 100644
index 0000000000..e604ae9733
--- /dev/null
+++ b/packages/cli/src/commands/backend/bundle.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { Command } from 'commander';
+import fs from 'fs-extra';
+import { createDistWorkspace } from '../../lib/packager';
+import { paths } from '../../lib/paths';
+import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
+
+const PKG_PATH = 'package.json';
+const TARGET_DIR = 'dist-workspace';
+
+export default async (cmd: Command) => {
+ const targetDir = paths.resolveTarget(TARGET_DIR);
+ const pkgPath = paths.resolveTarget(PKG_PATH);
+ const pkg = await fs.readJson(pkgPath);
+
+ await fs.remove(targetDir);
+ await fs.mkdir(targetDir);
+ await createDistWorkspace([pkg.name], {
+ targetDir: targetDir,
+ buildDependencies: Boolean(cmd.build),
+ parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
+ skeleton: 'skeleton.tar',
+ });
+};
diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts
index 1ebe518cac..7302559498 100644
--- a/packages/cli/src/commands/index.ts
+++ b/packages/cli/src/commands/index.ts
@@ -44,6 +44,11 @@ export function registerCommands(program: CommanderStatic) {
.description('Build a backend plugin')
.action(lazy(() => import('./backend/build').then(m => m.default)));
+ program
+ .command('backend:__experimental__bundle__', { hidden: true })
+ .description('Bundle all backend packages into dist-workspace')
+ .action(lazy(() => import('./backend/bundle').then(m => m.default)));
+
program
.command('backend:build-image')
.allowUnknownOption(true)
@@ -154,6 +159,17 @@ export function registerCommands(program: CommanderStatic) {
)
.action(lazy(() => import('./config/validate').then(m => m.default)));
+ program
+ .command('versions:bump')
+ .description('Bump Backstage packages to the latest versions')
+ .action(lazy(() => import('./versions/bump').then(m => m.default)));
+
+ program
+ .command('versions:check')
+ .option('--fix', 'Fix any auto-fixable versioning problems')
+ .description('Check Backstage package versioning')
+ .action(lazy(() => import('./versions/lint').then(m => m.default)));
+
program
.command('prepack')
.description('Prepares a package for packaging before publishing')
diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts
new file mode 100644
index 0000000000..f4d69c52be
--- /dev/null
+++ b/packages/cli/src/commands/versions/bump.test.ts
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { resolve as resolvePath } from 'path';
+import { paths } from '../../lib/paths';
+import { mapDependencies } from '../../lib/versioning';
+import * as runObj from '../../lib/run';
+import bump from './bump';
+import { withLogCollector } from '@backstage/test-utils';
+
+const REGISTRY_VERSIONS: { [name: string]: string } = {
+ '@backstage/core': '1.0.6',
+ '@backstage/theme': '2.0.0',
+};
+
+const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+`;
+
+const lockfileMock = `${HEADER}
+"@backstage/core@^1.0.5":
+ version "1.0.6"
+ dependencies:
+ "@backstage/core-api" "^1.0.6"
+
+"@backstage/core@^1.0.3":
+ version "1.0.3"
+ dependencies:
+ "@backstage/core-api" "^1.0.3"
+
+"@backstage/theme@^1.0.0":
+ version "1.0.0"
+
+"@backstage/core-api@^1.0.6":
+ version "1.0.6"
+
+"@backstage/core-api@^1.0.3":
+ version "1.0.3"
+`;
+
+// This resulting lockfile isn't a real world example, since it doesn't include the package bumps
+const lockfileMockResult = `${HEADER}
+"@backstage/core-api@^1.0.3", "@backstage/core-api@^1.0.6":
+ version "1.0.6"
+
+"@backstage/core@^1.0.5":
+ version "1.0.6"
+ dependencies:
+ "@backstage/core-api" "^1.0.6"
+
+"@backstage/theme@^1.0.0":
+ version "1.0.0"
+`;
+
+describe('bump', () => {
+ afterEach(() => {
+ mockFs.restore();
+ jest.resetAllMocks();
+ });
+
+ it('should bump backstage dependencies', async () => {
+ // Make sure all modules involved in package discovery are in the module cache before we mock fs
+ await mapDependencies(paths.targetDir);
+
+ mockFs({
+ '/yarn.lock': lockfileMock,
+ '/lerna.json': JSON.stringify({
+ packages: ['packages/*'],
+ }),
+ '/packages/a/package.json': JSON.stringify({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '^1.0.5',
+ },
+ }),
+ '/packages/b/package.json': JSON.stringify({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '^1.0.3',
+ '@backstage/theme': '^1.0.0',
+ },
+ }),
+ });
+
+ paths.targetDir = '/';
+ jest
+ .spyOn(paths, 'resolveTargetRoot')
+ .mockImplementation((...paths) => resolvePath('/', ...paths));
+ jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
+ JSON.stringify({
+ type: 'inspect',
+ data: {
+ name: name,
+ 'dist-tags': {
+ latest: REGISTRY_VERSIONS[name],
+ },
+ },
+ }),
+ );
+ jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
+
+ const { log: logs } = await withLogCollector(['log'], async () => {
+ await bump();
+ });
+ expect(logs.filter(Boolean)).toEqual([
+ 'Checking for updates of @backstage/theme',
+ 'Checking for updates of @backstage/core',
+ 'Some packages are outdated, updating',
+ 'Removing lockfile entry for @backstage/core@^1.0.3 to bump to 1.0.6',
+ 'Bumping @backstage/theme in b to ^2.0.0',
+ "Running 'yarn install' to install new versions",
+ 'Removing duplicate dependencies from yarn.lock',
+ "Running 'yarn install' to remove duplicates from node_modules",
+ ]);
+
+ expect(runObj.runPlain).toHaveBeenCalledTimes(2);
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ '@backstage/core',
+ );
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ '@backstage/theme',
+ );
+
+ expect(runObj.run).toHaveBeenCalledTimes(2);
+ expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']);
+
+ const lockfileContents = await fs.readFile('/yarn.lock', 'utf8');
+ expect(lockfileContents).toBe(lockfileMockResult);
+
+ const packageA = await fs.readJson('/packages/a/package.json');
+ expect(packageA).toEqual({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '^1.0.5', // not bumped since new version is within range
+ },
+ });
+ const packageB = await fs.readJson('/packages/b/package.json');
+ expect(packageB).toEqual({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '^1.0.3', // not bumped
+ '@backstage/theme': '^2.0.0', // bumped since newer
+ },
+ });
+ });
+});
diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts
new file mode 100644
index 0000000000..1883d87bac
--- /dev/null
+++ b/packages/cli/src/commands/versions/bump.ts
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 semver from 'semver';
+import { resolve as resolvePath } from 'path';
+import { run } from '../../lib/run';
+import { paths } from '../../lib/paths';
+import {
+ mapDependencies,
+ fetchPackageInfo,
+ Lockfile,
+} from '../../lib/versioning';
+import { includedFilter, forbiddenDuplicatesFilter } from './lint';
+
+const DEP_TYPES = [
+ 'dependencies',
+ 'devDependencies',
+ 'peerDependencies',
+ 'optionalDependencies',
+];
+
+type PkgVersionInfo = {
+ range: string;
+ name: string;
+ location: string;
+};
+
+export default async () => {
+ const lockfilePath = paths.resolveTargetRoot('yarn.lock');
+
+ // First we discover all Backstage dependencies within our own repo
+ const dependencyMap = await mapDependencies(paths.targetDir);
+
+ // Next check with the package registry to see which dependency ranges we need to bump
+ const versionBumps = new Map();
+ // Track package versions that we want to remove from yarn.lock in order to trigger a bump
+ const unlocked = Array<{ name: string; range: string; latest: string }>();
+ await workerThreads(16, dependencyMap.entries(), async ([name, pkgs]) => {
+ console.log(`Checking for updates of ${name}`);
+ const info = await fetchPackageInfo(name);
+ const latest = info['dist-tags'].latest;
+ if (!latest) {
+ throw new Error(`No latest version found for ${name}`);
+ }
+
+ for (const pkg of pkgs) {
+ if (semver.satisfies(latest, pkg.range)) {
+ if (semver.minVersion(pkg.range)?.version !== latest) {
+ unlocked.push({ name, range: pkg.range, latest });
+ }
+ continue;
+ }
+ versionBumps.set(
+ pkg.name,
+ (versionBumps.get(pkg.name) ?? []).concat({
+ name,
+ location: pkg.location,
+ range: `^${latest}`, // TODO(Rugvip): Option to use something else than ^?
+ }),
+ );
+ }
+ });
+
+ console.log();
+
+ // Write all discovered version bumps to package.json in this repo
+ if (versionBumps.size === 0 && unlocked.length === 0) {
+ console.log('All Backstage packages are up to date!');
+ } else {
+ console.log('Some packages are outdated, updating');
+ console.log();
+
+ if (unlocked.length > 0) {
+ const lockfile = await Lockfile.load(lockfilePath);
+ for (const { name, range, latest } of unlocked) {
+ // Don't bother removing lockfile entries if they're already on the correct version
+ const existingEntry = lockfile.get(name)?.find(e => e.range === range);
+ if (existingEntry?.version === latest) {
+ continue;
+ }
+ console.log(
+ `Removing lockfile entry for ${name}@${range} to bump to ${latest}`,
+ );
+ lockfile.remove(name, range);
+ }
+ await lockfile.save();
+ }
+
+ await workerThreads(16, versionBumps.entries(), async ([name, deps]) => {
+ const pkgPath = resolvePath(deps[0].location, 'package.json');
+ const pkgJson = await fs.readJson(pkgPath);
+
+ for (const dep of deps) {
+ console.log(`Bumping ${dep.name} in ${name} to ${dep.range}`);
+
+ for (const depType of DEP_TYPES) {
+ if (depType in pkgJson && dep.name in pkgJson[depType]) {
+ pkgJson[depType][dep.name] = dep.range;
+ }
+ }
+ }
+
+ await fs.writeJson(pkgPath, pkgJson, { spaces: 2 });
+ });
+
+ console.log();
+ console.log("Running 'yarn install' to install new versions");
+ console.log();
+ await run('yarn', ['install']);
+ }
+
+ console.log();
+
+ // Finally we make sure the new lockfile doesn't have any duplicates
+ const lockfile = await Lockfile.load(lockfilePath);
+ const result = lockfile.analyze({
+ filter: includedFilter,
+ });
+
+ if (result.newVersions.length > 0) {
+ console.log();
+ console.log('Removing duplicate dependencies from yarn.lock');
+ lockfile.replaceVersions(result.newVersions);
+ await lockfile.save();
+
+ console.log(
+ "Running 'yarn install' to remove duplicates from node_modules",
+ );
+ console.log();
+ await run('yarn', ['install']);
+
+ console.log();
+ }
+
+ const forbiddenNewRanges = result.newRanges.filter(({ name }) =>
+ forbiddenDuplicatesFilter(name),
+ );
+ if (forbiddenNewRanges.length > 0) {
+ throw new Error(
+ `Version bump failed for ${forbiddenNewRanges
+ .map(i => i.name)
+ .join(', ')}`,
+ );
+ }
+};
+
+async function workerThreads(
+ count: number,
+ items: IterableIterator,
+ fn: (item: T) => Promise,
+) {
+ const queue = Array.from(items);
+
+ async function pop() {
+ const item = queue.pop();
+ if (!item) {
+ return;
+ }
+
+ await fn(item);
+ await pop();
+ }
+
+ return Promise.all(
+ Array(count)
+ .fill(0)
+ .map(() => pop()),
+ );
+}
diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts
new file mode 100644
index 0000000000..aa0bcd6f6a
--- /dev/null
+++ b/packages/cli/src/commands/versions/lint.ts
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { Command } from 'commander';
+import { Lockfile } from '../../lib/versioning';
+import { paths } from '../../lib/paths';
+import partition from 'lodash/partition';
+
+// Packages that we try to avoid duplicates for
+const INCLUDED = [/^@backstage\//];
+
+export const includedFilter = (name: string) =>
+ INCLUDED.some(pattern => pattern.test(name));
+
+// Packages that are not allowed to have any duplicates
+const FORBID_DUPLICATES = [
+ /^@backstage\/core$/,
+ /^@backstage\/core-api$/,
+ /^@backstage\/plugin-/,
+];
+
+export const forbiddenDuplicatesFilter = (name: string) =>
+ FORBID_DUPLICATES.some(pattern => pattern.test(name));
+
+export default async (cmd: Command) => {
+ const fix = Boolean(cmd.fix);
+
+ let success = true;
+
+ const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
+ const result = lockfile.analyze({
+ filter: includedFilter,
+ });
+
+ logArray(
+ result.invalidRanges,
+ "The following packages versions are invalid and can't be analyzed:",
+ e => ` ${e.name} @ ${e.range}`,
+ );
+
+ if (fix) {
+ lockfile.replaceVersions(result.newVersions);
+ await lockfile.save();
+ } else {
+ const [
+ newVersionsForbidden,
+ newVersionsAllowed,
+ ] = partition(result.newVersions, ({ name }) =>
+ forbiddenDuplicatesFilter(name),
+ );
+ if (newVersionsForbidden.length && !fix) {
+ success = false;
+ }
+
+ logArray(
+ newVersionsForbidden,
+ 'The following packages must be deduplicated, this can be done automatically with --fix',
+ e =>
+ ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`,
+ );
+ logArray(
+ newVersionsAllowed,
+ 'The following packages can be deduplicated, this can be done automatically with --fix',
+ e =>
+ ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`,
+ );
+ }
+
+ const [newRangesForbidden, newRangesAllowed] = partition(
+ result.newRanges,
+ ({ name }) => forbiddenDuplicatesFilter(name),
+ );
+ if (newRangesForbidden.length) {
+ success = false;
+ }
+
+ logArray(
+ newRangesForbidden,
+ 'The following packages must be deduplicated by updating dependencies in package.json',
+ e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`,
+ );
+ logArray(
+ newRangesAllowed,
+ 'The following packages can be deduplicated by updating dependencies in package.json',
+ e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`,
+ );
+
+ if (!success) {
+ throw new Error('Failed versioning check');
+ }
+};
+
+function logArray(arr: T[], header: string, each: (item: T) => string) {
+ if (arr.length === 0) {
+ return;
+ }
+
+ console.log(header);
+ console.log();
+ for (const e of arr) {
+ console.log(each(e));
+ }
+ console.log();
+}
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index d198f5507c..a5cdd1300c 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -60,7 +60,7 @@ export async function serveBundle(options: ServeOptions) {
proxy: pkg.proxy,
});
- await new Promise((resolve, reject) => {
+ await new Promise((resolve, reject) => {
server.listen(port, url.hostname, (err?: Error) => {
if (err) {
reject(err);
diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts
index 69d3d92640..571c75a39d 100644
--- a/packages/cli/src/lib/run.ts
+++ b/packages/cli/src/lib/run.ts
@@ -102,7 +102,7 @@ export async function waitForExit(
return;
}
- await new Promise((resolve, reject) => {
+ await new Promise((resolve, reject) => {
child.once('error', error => reject(error));
child.once('exit', code => {
if (code) {
diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts
new file mode 100644
index 0000000000..89126c7ce1
--- /dev/null
+++ b/packages/cli/src/lib/versioning/Lockfile.test.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { Lockfile } from './Lockfile';
+
+const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+`;
+
+const mockA = `${HEADER}
+a@^1:
+ version "1.0.1"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+ dependencies:
+ b "^2"
+
+b@2.0.x:
+ version "2.0.1"
+
+b@^2:
+ version "2.0.0"
+`;
+
+const mockADedup = `${HEADER}
+a@^1:
+ version "1.0.1"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+ dependencies:
+ b "^2"
+
+b@2.0.x, b@^2:
+ version "2.0.1"
+`;
+
+const mockB = `${HEADER}
+"@s/a@*", "@s/a@1 || 2", "@s/a@^1":
+ version "1.0.1"
+
+"@s/a@^2.0.x":
+ version "2.0.0"
+`;
+
+const mockBDedup = `${HEADER}
+"@s/a@*", "@s/a@1 || 2", "@s/a@^2.0.x":
+ version "2.0.0"
+
+"@s/a@^1":
+ version "1.0.1"
+`;
+
+describe('Lockfile', () => {
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should load and serialize mockA', async () => {
+ mockFs({
+ '/yarn.lock': mockA,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ expect(lockfile.get('a')).toEqual([{ range: '^1', version: '1.0.1' }]);
+ expect(lockfile.get('b')).toEqual([
+ { range: '2.0.x', version: '2.0.1' },
+ { range: '^2', version: '2.0.0' },
+ ]);
+ expect(lockfile.toString()).toBe(mockA);
+ });
+
+ it('should deduplicate and save mockA', async () => {
+ mockFs({
+ '/yarn.lock': mockA,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ const result = lockfile.analyze();
+ expect(result).toEqual({
+ invalidRanges: [],
+ newRanges: [],
+ newVersions: [
+ {
+ name: 'b',
+ range: '^2',
+ oldVersion: '2.0.0',
+ newVersion: '2.0.1',
+ },
+ ],
+ });
+
+ expect(lockfile.toString()).toBe(mockA);
+ lockfile.replaceVersions(result.newVersions);
+ expect(lockfile.toString()).toBe(mockADedup);
+
+ await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA);
+ await expect(lockfile.save()).resolves.toBeUndefined();
+ await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup);
+ });
+
+ it('should deduplicate mockB', async () => {
+ mockFs({
+ '/yarn.lock': mockB,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ const result = lockfile.analyze();
+ expect(result).toEqual({
+ invalidRanges: [],
+ newRanges: [
+ {
+ name: '@s/a',
+ oldRange: '^1',
+ newRange: '^2.0.x',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ ],
+ newVersions: [
+ {
+ name: '@s/a',
+ range: '*',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ {
+ name: '@s/a',
+ range: '1 || 2',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ ],
+ });
+
+ expect(lockfile.toString()).toBe(mockB);
+ lockfile.replaceVersions(result.newVersions);
+ expect(lockfile.toString()).toBe(mockBDedup);
+ });
+});
diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts
new file mode 100644
index 0000000000..f9bc789d95
--- /dev/null
+++ b/packages/cli/src/lib/versioning/Lockfile.ts
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 semver from 'semver';
+import {
+ parse as parseLockfile,
+ stringify as stringifyLockfile,
+} from '@yarnpkg/lockfile';
+
+const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
+
+type LockfileData = {
+ [entry: string]: {
+ version: string;
+ resolved?: string;
+ integrity?: string;
+ dependencies?: { [name: string]: string };
+ };
+};
+
+type LockfileQueryEntry = {
+ range: string;
+ version: string;
+};
+
+/** Entries that have an invalid version range, for example an NPM tag */
+type AnalyzeResultInvalidRange = {
+ name: string;
+ range: string;
+};
+
+/** Entries that can be deduplicated by bumping to an existing higher version */
+type AnalyzeResultNewVersion = {
+ name: string;
+ range: string;
+ oldVersion: string;
+ newVersion: string;
+};
+
+/** Entries that would need a dependency update in package.json to be deduplicated */
+type AnalyzeResultNewRange = {
+ name: string;
+ oldRange: string;
+ newRange: string;
+ oldVersion: string;
+ newVersion: string;
+};
+
+type AnalyzeResult = {
+ invalidRanges: AnalyzeResultInvalidRange[];
+ newVersions: AnalyzeResultNewVersion[];
+ newRanges: AnalyzeResultNewRange[];
+};
+
+export class Lockfile {
+ static async load(path: string) {
+ const lockfileContents = await fs.readFile(path, 'utf8');
+ const lockfile = parseLockfile(lockfileContents);
+ if (lockfile.type !== 'success') {
+ throw new Error(`Failed yarn.lock parse with ${lockfile.type}`);
+ }
+
+ const data = lockfile.object as LockfileData;
+ const packages = new Map();
+
+ for (const [key, value] of Object.entries(data)) {
+ const [, name, range] = ENTRY_PATTERN.exec(key) ?? [];
+ if (!name) {
+ throw new Error(`Failed to parse yarn.lock entry '${key}'`);
+ }
+
+ let queries = packages.get(name);
+ if (!queries) {
+ queries = [];
+ packages.set(name, queries);
+ }
+ queries.push({ range, version: value.version });
+ }
+
+ return new Lockfile(path, packages, data);
+ }
+
+ private constructor(
+ private readonly path: string,
+ private readonly packages: Map,
+ private readonly data: LockfileData,
+ ) {}
+
+ get(name: string): LockfileQueryEntry[] | undefined {
+ return this.packages.get(name);
+ }
+
+ /** Analyzes the lockfile to identify possible actions and warnings for the entries */
+ analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult {
+ const { filter } = options ?? {};
+ const result: AnalyzeResult = {
+ invalidRanges: [],
+ newVersions: [],
+ newRanges: [],
+ };
+
+ for (const [name, allEntries] of this.packages) {
+ if (filter && !filter(name)) {
+ continue;
+ }
+
+ // Get rid of and signal any invalid ranges upfront
+ const invalid = allEntries.filter(e => !semver.validRange(e.range));
+ result.invalidRanges.push(
+ ...invalid.map(({ range }) => ({ name, range })),
+ );
+
+ // Grab all valid entries, if there aren't at least 2 different valid ones we're done
+ const entries = allEntries.filter(e => semver.validRange(e.range));
+ if (entries.length < 2) {
+ continue;
+ }
+
+ // Find all versions currently in use
+ const versions = Array.from(
+ new Set(entries.map(e => e.version)),
+ ).sort((v1, v2) => semver.rcompare(v1, v2));
+
+ // If we're not using at least 2 different versions we're done
+ if (versions.length < 2) {
+ continue;
+ }
+
+ const acceptedVersions = new Set();
+ for (const { version, range } of entries) {
+ // Finds the highest matching version from the the known versions
+ // TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one
+ const acceptedVersion = versions.find(v => semver.satisfies(v, range));
+ if (!acceptedVersion) {
+ throw new Error(
+ `No existing version was accepted for range ${range}, searching through ${versions}`,
+ );
+ }
+
+ if (acceptedVersion !== version) {
+ result.newVersions.push({
+ name,
+ range,
+ newVersion: acceptedVersion,
+ oldVersion: version,
+ });
+ }
+
+ acceptedVersions.add(acceptedVersion);
+ }
+
+ // If all ranges were able to accept the same version, we're done
+ if (acceptedVersions.size === 1) {
+ continue;
+ }
+
+ // Find the max version that we may want bump older packages to
+ const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0];
+ // Find all existing ranges that satisfy the new max version, and pick the one that
+ // results in the highest minimum allowed version, usually being the more specific one
+ const maxEntry = entries
+ .filter(e => semver.satisfies(maxVersion, e.range))
+ .map(e => ({ e, min: semver.minVersion(e.range) }))
+ .filter(p => p.min)
+ .sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e;
+ if (!maxEntry) {
+ throw new Error(
+ `No entry found that satisfies max version '${maxVersion}'`,
+ );
+ }
+
+ // Find all entries that don't satisfy the max version
+ for (const { version, range } of entries) {
+ if (semver.satisfies(maxVersion, range)) {
+ continue;
+ }
+
+ result.newRanges.push({
+ name,
+ oldRange: range,
+ newRange: maxEntry.range,
+ oldVersion: version,
+ newVersion: maxVersion,
+ });
+ }
+ }
+
+ return result;
+ }
+
+ remove(name: string, range: string): boolean {
+ const query = `${name}@${range}`;
+ const existed = Boolean(this.data[query]);
+ delete this.data[query];
+
+ const newEntries = this.packages.get(name)?.filter(e => e.range !== range);
+ if (newEntries) {
+ this.packages.set(name, newEntries);
+ }
+
+ return existed;
+ }
+
+ /** Modifies the lockfile by bumping packages to the suggested versions */
+ replaceVersions(results: AnalyzeResultNewVersion[]) {
+ for (const { name, range, oldVersion, newVersion } of results) {
+ const query = `${name}@${range}`;
+
+ // Update the backing data
+ const entryData = this.data[query];
+ if (!entryData) {
+ throw new Error(`No entry data for ${query}`);
+ }
+ if (entryData.version !== oldVersion) {
+ throw new Error(
+ `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
+ );
+ }
+
+ // Modifying the data in the entry is not enough, we need to reference an existing version object
+ const matchingEntry = Object.entries(this.data).find(
+ ([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion,
+ );
+ if (!matchingEntry) {
+ throw new Error(
+ `No matching entry found for ${name} at version ${newVersion}`,
+ );
+ }
+ this.data[query] = matchingEntry[1];
+
+ // Update our internal data structure
+ const entry = this.packages.get(name)?.find(e => e.range === range);
+ if (!entry) {
+ throw new Error(`No entry data for ${query}`);
+ }
+ if (entry.version !== oldVersion) {
+ throw new Error(
+ `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
+ );
+ }
+ entry.version = newVersion;
+ }
+ }
+
+ async save() {
+ await fs.writeFile(this.path, this.toString(), 'utf8');
+ }
+
+ toString() {
+ return stringifyLockfile(this.data);
+ }
+}
diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli/src/lib/versioning/index.ts
new file mode 100644
index 0000000000..71fb7647ce
--- /dev/null
+++ b/packages/cli/src/lib/versioning/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { Lockfile } from './Lockfile';
+export { fetchPackageInfo, mapDependencies } from './packages';
diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts
new file mode 100644
index 0000000000..8155d4b5c0
--- /dev/null
+++ b/packages/cli/src/lib/versioning/packages.test.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 mockFs from 'mock-fs';
+import * as runObj from '../run';
+import { paths } from '../paths';
+import { fetchPackageInfo, mapDependencies } from './packages';
+
+describe('fetchPackageInfo', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should forward info', async () => {
+ jest
+ .spyOn(runObj, 'runPlain')
+ .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
+
+ await expect(fetchPackageInfo('my-package')).resolves.toEqual({
+ the: 'data',
+ });
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ 'my-package',
+ );
+ });
+});
+
+describe('mapDependencies', () => {
+ afterEach(() => {
+ mockFs.restore();
+ jest.resetAllMocks();
+ });
+
+ it('should read dependencies', async () => {
+ // Make sure all modules involved in package discovery are in the module cache before we mock fs
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(paths.targetDir);
+ await project.getPackages();
+
+ mockFs({
+ '/lerna.json': JSON.stringify({
+ packages: ['pkgs/*'],
+ }),
+ '/pkgs/a/package.json': JSON.stringify({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '1 || 2',
+ },
+ }),
+ '/pkgs/b/package.json': JSON.stringify({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '3',
+ '@backstage/cli': '^0',
+ },
+ }),
+ });
+
+ const oldDir = paths.targetDir;
+ paths.targetDir = '/';
+
+ const dependencyMap = await mapDependencies(paths.targetDir);
+ expect(Array.from(dependencyMap)).toEqual([
+ [
+ '@backstage/core',
+ [
+ {
+ name: 'a',
+ range: '1 || 2',
+ location: '/pkgs/a',
+ },
+ {
+ name: 'b',
+ range: '3',
+ location: '/pkgs/b',
+ },
+ ],
+ ],
+ [
+ '@backstage/cli',
+ [
+ {
+ name: 'b',
+ range: '^0',
+ location: '/pkgs/b',
+ },
+ ],
+ ],
+ ]);
+
+ paths.targetDir = oldDir;
+ });
+});
diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts
new file mode 100644
index 0000000000..76e5dc49b0
--- /dev/null
+++ b/packages/cli/src/lib/versioning/packages.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { runPlain } from '../../lib/run';
+
+const PREFIX = '@backstage';
+
+const DEP_TYPES = [
+ 'dependencies',
+ 'devDependencies',
+ 'peerDependencies',
+ 'optionalDependencies',
+];
+
+// Package data as returned by `yarn info`
+type YarnInfoInspectData = {
+ name: string;
+ 'dist-tags': { latest: string };
+ versions: string[];
+ time: { [version: string]: string };
+};
+
+// Possible `yarn info` output
+type YarnInfo = {
+ type: 'inspect';
+ data: YarnInfoInspectData | { type: string; data: unknown };
+};
+
+type PkgVersionInfo = {
+ range: string;
+ name: string;
+ location: string;
+};
+
+export async function fetchPackageInfo(
+ name: string,
+): Promise {
+ const output = await runPlain('yarn', 'info', '--json', name);
+ const info = JSON.parse(output) as YarnInfo;
+ if (info.type !== 'inspect') {
+ throw new Error(`Received unknown yarn info for ${name}, ${output}`);
+ }
+
+ return info.data as YarnInfoInspectData;
+}
+
+/** Map all dependencies in the repo as dependency => dependents */
+export async function mapDependencies(
+ targetDir: string,
+): Promise