diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d737dd620e..de0655cdbc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,9 +4,11 @@ # The last matching pattern takes precedence. # https://help.github.com/articles/about-codeowners/ -* @spotify/backstage-core -/docs/features/techdocs @spotify/techdocs-core -/plugins/techdocs @spotify/techdocs-core -/plugins/techdocs-backend @spotify/techdocs-core -/packages/techdocs-cli @spotify/techdocs-core -/packages/techdocs-container @spotify/techdocs-core +* @spotify/backstage-core +/docs/features/techdocs @spotify/techdocs-core +/plugins/techdocs @spotify/techdocs-core +/plugins/techdocs-backend @spotify/techdocs-core +/packages/techdocs-cli @spotify/techdocs-core +/packages/techdocs-container @spotify/techdocs-core +/.github/workflows/techdocs.yml @spotify/techdocs-core +/.github/workflows/techdocs-pypi.yml @spotify/techdocs-core diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 7704aa36cd..32fb15d329 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -13,6 +13,18 @@ jobs: build: runs-on: ${{ matrix.os }} + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432/tcp + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + strategy: matrix: os: [ubuntu-latest] @@ -44,6 +56,11 @@ jobs: - run: yarn tsc - run: yarn build - name: verify app and plugin creation + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres run: | sudo sysctl fs.inotify.max_user_watches=524288 node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index f154c586fa..0137e4b9dc 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -76,8 +76,5 @@ jobs: - name: verify plugin template run: yarn lerna -- run diff -- --check - - name: bundle example app - run: yarn bundle - - name: verify storybook run: yarn workspace storybook build-storybook diff --git a/.github/workflows/techdocs-pypi.yml b/.github/workflows/techdocs-pypi.yml new file mode 100644 index 0000000000..7680cb98b0 --- /dev/null +++ b/.github/workflows/techdocs-pypi.yml @@ -0,0 +1,37 @@ +name: Master Build TechDocs PyPI Publish + +on: + push: + branches: [master] + paths: + - '.github/workflows/techdocs-pypi.yml' + - 'packages/techdocs-container/**' + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest] + python-version: [3.7] + + steps: + # Publish techdocs-core to PyPI + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@master + with: + python-version: 3.7 + - name: Build Python distribution + working-directory: ./packages/techdocs-container/techdocs-core + run: | + pip install wheel + rm -rf dist + python setup.py bdist_wheel sdist --formats gztar + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.PYPI_API_KEY }} + packages_dir: ./packages/techdocs-container/techdocs-core/dist diff --git a/.github/workflows/techdocs.yml b/.github/workflows/techdocs.yml index 491b5693de..95b68c9376 100644 --- a/.github/workflows/techdocs.yml +++ b/.github/workflows/techdocs.yml @@ -33,12 +33,12 @@ jobs: push: false # Lint Python code for techdocs-core package - - name: prepare python environment + - name: Prepare Python environment run: | python3 -m pip install --index-url https://pypi.org/simple/ setuptools python3 -m pip install --upgrade pip python3 -m pip install --index-url https://pypi.org/simple/ -r $TECHDOCS_CORE_PATH/requirements.txt - - name: lint techdocs-core package + - name: Lint techdocs-core package run: | python3 -m black --check $TECHDOCS_CORE_PATH/src diff --git a/.gitignore b/.gitignore index 98f63e7dfa..c1bcf5a4b4 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,6 @@ dist # Temporary change files created by Vim *.swp + +# MkDocs build output +site diff --git a/ADOPTERS.md b/ADOPTERS.md index d0f8c0a97b..7a96198529 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,3 +1,13 @@ -| Organization | Contact | Description of Use | -| ------------ | ------- | ------------------ | -| [Spotify](https://www.spotify.com) |[@alund](https://github.com/alund)| Main interface towards all of Spotify's infrastructure and technical documentation.| +| Organization | Contact | Description of Use | +| --------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dca9e678dc..6200f3235b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,10 @@ If you are proposing a feature: - Remember that this is a volunteer-driven project, and that contributions are welcome :) +## Add your company to ADOPTERS + +Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) really helps the project. + # Get Started! So...feel ready to jump in? Let's do this. Head over to the [Getting Started guide](https://github.com/spotify/backstage#getting-started) 👏🏻💯 diff --git a/README.md b/README.md index 40f12f3b3e..8e7574e128 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,9 @@ The Backstage platform consists of a number of different components: - **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/app`) and you can easily get started with your own app by [creating one](docs/create-an-app.md). - [**plugins**](https://github.com/spotify/backstage/tree/master/plugins) - Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform API's and reusable UI components. Plugins can fetch data either from the _backend_ or through any RESTful API exposed through the _proxy_. - [**service catalog**](https://github.com/spotify/backstage/tree/master/packages/backend) - Service that holds the model of your software ecosystem, including organisational information and what team owns what software. The backend also has a Plugin model for extending its graph. -- **proxy** \* - Terminates HTTPS and exposes any RESTful API to Plugins. +- [**proxy**](https://github.com/spotify/backstage/tree/master/plugins/proxy-backend) - Terminates HTTPS and exposes any RESTful API to Plugins. - **identity** - A backend service that holds your organisation's metadata. -_\* not yet released_ - ## Getting started To run a Backstage app, you will need to have the following installed: @@ -86,17 +84,16 @@ And that's it! You are good to go 👍 ### Next step -Take a look at the [Getting Started](docs/getting-started/README.md) guide to learn more about how to extend the functionality with Plugins. +Take a look at the [Getting Started](docs/getting-started/index.md) guide to learn how to set up Backstage, and how to develop on the platform. ## Documentation -- [Getting Started](docs/getting-started/README.md) -- [Create a Backstage App](docs/create-an-app.md) -- [Architecture](docs/architecture-terminology.md) ([Decisions](docs/architecture-decisions)) -- [API references](docs/reference/README.md) -- [Designing for Backstage](docs/design.md) +- [Main documentation](docs/README.md) +- [Service Catalog](docs/features/software-catalog/index.md) +- [Create a Backstage App](docs/getting-started/create-an-app.md) +- [Architecture](docs/overview/architecture-terminology.md) ([Decisions](docs/architecture-decisions/index.md)) +- [Designing for Backstage](docs/dls/design.md) - [Storybook - UI components](http://storybook.backstage.io) -- [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md) ## Contributing @@ -109,6 +106,7 @@ We would love your help in building Backstage! See [CONTRIBUTING](CONTRIBUTING.m - [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the technical direction - [FAQ](docs/FAQ.md) - Frequently Asked Questions - [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll +- [Adopters](ADOPTERS.md) - Companies already using Backstage - [Blog](https://backstage.io/blog/) - Announcements and updates - [Newsletter](https://mailchi.mp/spotify/backstage-community) - Give us a star ⭐️ - If you are using Backstage or think it is an interesting project, we would love a star ❤️ diff --git a/app-config.yaml b/app-config.yaml index cd8494613a..e5f872201d 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -15,10 +15,82 @@ proxy: target: 'https://circleci.com/api/v1.1' changeOrigin: true pathRewrite: - '^/circleci/api/': '/' + '^/proxy/circleci/api/': '/' organization: name: Spotify techdocs: storageUrl: https://techdocs-mock-sites.storage.googleapis.com + +sentry: + organization: spotify + +auth: + providers: + google: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_GOOGLE_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GOOGLE_CLIENT_SECRET + github: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_GITHUB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITHUB_CLIENT_SECRET + gitlab: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_GITLAB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITLAB_CLIENT_SECRET + audience: + $secret: + env: GITLAB_BASE_URL + # saml: + # development: + # entryPoint: "http://localhost:7001/" + # issuer: "passport-saml" + okta: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_OKTA_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OKTA_CLIENT_SECRET + audience: + $secret: + env: AUTH_OKTA_AUDIENCE + oauth2: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_OAUTH2_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OAUTH2_CLIENT_SECRET + authorizationURL: + $secret: + env: AUTH_OAUTH2_AUTH_URL + tokenURL: + $secret: + env: AUTH_OAUTH2_TOKEN_URL diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000000..d32928b4e3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,16 @@ +# Make sure that before you +# run the docker-compose that you have run +# $ yarn docker-build:all +version: '3' + +services: + frontend: + image: 'spotify/backstage:latest' + ports: + - '3000:80' + backend: + image: 'example-backend:latest' + ports: + - '7000:7000' + environment: + NODE_ENV: development diff --git a/docker/run.sh b/docker/run.sh index c6800cd0d8..9cecd88f63 100755 --- a/docker/run.sh +++ b/docker/run.sh @@ -22,7 +22,10 @@ function inject_config() { >&2 echo "Runtime app config: $config" local main_js - main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/*.chunk.js)" + if ! main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/static/*.js)"; then + echo "Runtime config already written" + return + fi echo "Writing runtime config to ${main_js}" # escape ' and " twice, for both sed and json diff --git a/docs/README.md b/docs/README.md index 86ca88b75c..b6ff4fbd6d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,100 @@ -# Documentation +# Documentation structure -Check out or see the table of contents below. +**Note!** This documentation structure is very much work in progress. If (when, +really 😆) you find broken links or missing content, please create an issue or, +better yet, a pull request. -- [Architecture and Terminology](architecture-terminology.md) -- [Getting Started](getting-started/README.md) -- [References](reference/README.md) -- [Publishing](publishing.md) -- [Designing for Backstage](design.md) -- [How to Add an Auth Provider](auth/add-auth-provider.md) +- Overview + - [What is Backstage?](overview/what-is-backstage.md) + - [Backstage architecture](overview/architecture-overview.md) + - [Architecture and terminology](overview/architecture-terminology.md) + - [Roadmap](overview/roadmap.md) + - Getting started + - [Running Backstage locally](getting-started/index.md) + - [Installation](getting-started/installation.md) + - [Local development](getting-started/development-environment.md) + - [Demo deployment](https://backstage-demo.roadie.io) + - Production deployments + - [Create an App](getting-started/create-an-app.md) + - App configuration + - [Configuring App with plugins](getting-started/configure-app-with-plugins.md) + - [Customize the look-and-feel of your App](getting-started/app-custom-theme.md) + - Deployment scenarios + - [Kubernetes](getting-started/deployment-k8s.md) + - [Other](getting-started/deployment-other.md) + - Features + - Software Catalog + - [Overview](features/software-catalog/index.md) + - [System model](features/software-catalog/system-model.md) + - [YAML File Format](features/software-catalog/descriptor-format.md) + - [Populating the catalog](features/software-catalog/populating.md) + - [Extending the model](features/software-catalog/extending-the-model.md) + - [External integrations](features/software-catalog/external-integrations.md) + - [API](features/software-catalog/api.md) + - Software creation templates + - [Overview](features/software-templates/index.md) + - [Adding templates](features/software-templates/adding-templates.md) + - Extending the Scaffolder: + - [Overview](features/software-templates/extending/index.md) + - [Create your own Templater](features/software-templates/extending/create-your-own-templater.md) + - [Create your own Publisher](features/software-templates/extending/create-your-own-publisher.md) + - [Create your own Preparer](features/software-templates/extending/create-your-own-preparer.md) + - Docs-like-code + - [Overview](features/techdocs/README.md) + - [Getting Started](features/techdocs/getting-started.md) + - [Concepts](features/techdocs/concepts.md) + - [Reading Documentation](features/techdocs/reading-documentation.md) + - [Writing Documentation](features/techdocs/writing-documentation.md) + - [Publishing Documentation](features/techdocs/publishing-documentation.md) + - [Contributing](features/techdocs/contributing.md) + - [Debugging](features/techdocs/debugging.md) + - [FAQ](features/techdocs/FAQ.md) + - Plugins + - [Overview](plugins/index.md) + - [Existing plugins](plugins/existing-plugins.md) + - [Creating a new plugin](plugins/create-a-plugin.md) + - [Developing a plugin](plugins/plugin-development.md) + - [Structure of a plugin](plugins/structure-of-a-plugin.md) + - Backends and APIs + - [Proxying](plugins/proxying.md) + - [Backstage backend plugin](plugins/backend-plugin.md) + - [Call existing API](plugins/call-existing-api.md) + - Testing + - [Overview](plugins/testing.md) + - Publishing + - [Open source and NPM](plugins/publishing.md) + - [Private/internal (non-open source)](plugins/publish-private.md) + - Authentication and identity + - [Overview](auth/index.md) + - [Add auth provider](auth/add-auth-provider.md) + - [Auth backend](auth/auth-backend.md) + - [OAuth](auth/oauth.md) + - [Glossary](auth/glossary.md) + - Designing for Backstage + - [Backstage Design Language System (DLS)](dls/design.md) + - [Storybook -- reusable UI components](http://storybook.backstage.io) + - [Contributing to Storybook](dls/contributing-to-storybook.md) + - [Figma resources](dls/figma.md) + - API references + - TypeScript API + - [Utility APIs](api/utility-apis.md) + - [Utility API References](reference/utility-apis/README.md) + - [createPlugin](reference/createPlugin.md) + - [createPlugin-feature-flags](reference/createPlugin-feature-flags.md) + - [createPlugin-router](reference/createPlugin-router.md) + - Backend APIs + - [Backend](api/backend.md) + - Tutorials + - [Overview](tutorials/index.md) + - Architecture Decision Records (ADRs) + - [Overview](architecture-decisions/index.md) + - [ADR001 - Architecture Decision Record (ADR) log](architecture-decisions/adr001-add-adr-log.md) + - [ADR002 - Default Software Catalog File Format](architecture-decisions/adr002-default-catalog-file-format.md) + - [ADR003 - Avoid Default Exports and Prefer Named Exports](architecture-decisions/adr003-avoid-default-exports.md) + - [ADR004 - Module Export Structure](architecture-decisions/adr004-module-export-structure.md) + - [ADR005 - Catalog Core Entities](architecture-decisions/adr005-catalog-core-entities.md) + - [ADR006 - Avoid React.FC and React.SFC](architecture-decisions/adr006-avoid-react-fc.md) + - [ADR007 - Use MSW for Mocking Network Requests](architecture-decisions/adr007-use-msw-to-mock-service-requests.md) + - [Contribute](../CONTRIBUTING.md) + - [Support](overview/support.md) + - [FAQ](FAQ.md) diff --git a/docs/api/backend.md b/docs/api/backend.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/getting-started/utility-apis-fig1.svg b/docs/api/utility-apis-fig1.svg similarity index 100% rename from docs/getting-started/utility-apis-fig1.svg rename to docs/api/utility-apis-fig1.svg diff --git a/docs/getting-started/utility-apis.md b/docs/api/utility-apis.md similarity index 98% rename from docs/getting-started/utility-apis.md rename to docs/api/utility-apis.md index 2b15da0295..f87db15e7e 100644 --- a/docs/getting-started/utility-apis.md +++ b/docs/api/utility-apis.md @@ -19,7 +19,8 @@ during their entire life cycle. Each Utility API is tied to an `ApiRef` instance, which is a global singleton object without any additional state or functionality, its only purpose is to reference Utility APIs. `ApiRef`s are create using `createApiRef`, which is -exported by `@backstage/core`. There are many predefined Utility APIs defined in +exported by `@backstage/core`. There are many +[predefined Utility APIs](../reference/utility-apis/README.md) defined in `@backstage/core`, and they're all exported with a name of the pattern `*ApiRef`, for example `errorApiRef`. diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md index 3da9313378..e6ab39aea4 100644 --- a/docs/architecture-decisions/adr001-add-adr-log.md +++ b/docs/architecture-decisions/adr001-add-adr-log.md @@ -1,6 +1,8 @@ -## Date: 2020-04-26 +# ADR001: Architecture Decision Record (ADR) log -## Title: Architecture Decision Record (ADR) log +| Created | Status | +| ---------- | ------ | +| 2020-04-26 | Open | ## Decision: A decision was made to store ADRs in a log in the project repository diff --git a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md new file mode 100644 index 0000000000..bdb221cea3 --- /dev/null +++ b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md @@ -0,0 +1,60 @@ +# ADR007: Use MSW to mock http requests + +## Context + +Network request mocking can be a total pain sometimes, in all different types of +tests, unit tests to e2e tests always have their own implementation of mocking +these requests. There's been traction in the outer community towards using this +library to mock network requests by using an express style declaration for +routes. react-testing-library suggests using this library instead of mocking +fetch directly wether this be in a browser or in node. + +https://github.com/mswjs/msw + +## Decision + +Moving forward, we have decided that any `fetch` or `XMLHTTPRequest` that +happens, should be mocked by using `msw`. + +Here is an example: + +```ts +import { setupWorker, rest } from 'msw'; + +const worker = setupWorker( + rest.get('*/user/:userId', (req, res, ctx) => { + return res( + ctx.json({ + firstName: 'John', + lastName: 'Maverick', + }), + ); + }), +); + +// Start the Mock Service Worker +worker.start(); +``` + +and in a more real life scenario, taken from +[CatalogClient.test.ts](https://github.com/spotify/backstage/blob/f3245c4f8f0b6b2625c4a6d5d50161b612fb4757/plugins/catalog/src/api/CatalogClient.test.ts) + +```ts +beforeEach(() => { + server.use( + rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); +}); + +it('should entities from correct endpoint', async () => { + const entities = await client.getEntities(); + expect(entities).toEqual(defaultResponse); +}); +``` + +## Consequences + +- A little more code to write +- Gradually will replace the codebase with `msw` diff --git a/docs/architecture-decisions/index.md b/docs/architecture-decisions/index.md new file mode 100644 index 0000000000..7762a9c827 --- /dev/null +++ b/docs/architecture-decisions/index.md @@ -0,0 +1,28 @@ +# Architecture Decision Records (ADR) + +The substantial architecture decisions made in the Backstage project lives here. +For more information about ADRs, when to write them, and why, please see +[this blog post](https://engineering.atspotify.com/2020/04/14/when-should-i-write-an-architecture-decision-record/). + +Records are never deleted but can be marked as superseded by new decisions or +deprecated. + +Records should be stored under the `architecture-decisions` directory. + +## Contributing + +### Creating an ADR + +- Copy `0000-template.md` to `docs/architecture-decisions/0000-my-decision.md` + (my-decision should be descriptive. Do not assign an ADR number.) +- Fill in the ADR following the guidelines in the template +- Submit a pull request +- Address and integrate feedback from the community +- Eventually, assign a number +- Add the full path of the ADR to the [`mkdocs.yml`](/mkdocs.yml) +- Merge the pull request + +## Superseding an ADR + +If an ADR supersedes an older ADR then the older ADR's status is changed to +superseded by ADR-XXXX and links to the new ADR. diff --git a/docs/auth/auth-backend.md b/docs/auth/auth-backend.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/auth/README.md b/docs/auth/index.md similarity index 96% rename from docs/auth/README.md rename to docs/auth/index.md index 9809f57168..6f9af17cba 100644 --- a/docs/auth/README.md +++ b/docs/auth/index.md @@ -25,8 +25,9 @@ as possible to create new plugins, and an auth solution based on user-to-server OAuth helps in that regard. The method with which frontend plugins request access to third party services is -through [Utility APIs](../getting-started/utility-apis.md) for each service -provider. For a full list of providers, see [TODO](#TODO). +through [Utility APIs](../api/utility-apis.md) for each service provider. For a +full list of providers, see the +[Utility API References](../reference/utility-apis/README.md). ### Identity - WIP diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 519cfe1d85..d348dd2f93 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -8,11 +8,12 @@ to various third party APIs. There are occasions when the user wants to perform actions towards third party services that require authorization via OAuth. Backstage provides standardized -[Utility APIs](../getting-started/utility-apis.md) such as the -[GoogleAuthApi](../../packages/core-api/src/apis/definitions/auth.ts) for that -use-case. Backstage also includes a set of implementations of these APIs that -integrate with the [auth-backend](../../plugins/auth-backend) plugin to provide -a popup-based OAuth flow. +[Utility APIs](../api/utility-apis.md) such as the +[GoogleAuthApi](https://github.com/spotify/backstage/blob/master/packages/core-api/src/apis/definitions/auth.ts) +for that use-case. Backstage also includes a set of implementations of these +APIs that integrate with the +[auth-backend](https://github.com/spotify/backstage/tree/master/plugins/auth-backend) +plugin to provide a popup-based OAuth flow. ## Background @@ -51,8 +52,9 @@ easier to make authenticated requests inside a plugin. ## OAuth Flow The following describes the OAuth flow implemented by the -[auth-backend](../../plugins/auth-backend) and -[DefaultAuthConnector](../../packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts) +[auth-backend](https://github.com/spotify/backstage/tree/master/plugins/auth-backend) +and +[DefaultAuthConnector](https://github.com/spotify/backstage/blob/master/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts) in `@backstage/core-api`. Component and APIs can request Access or ID Tokens from any available Auth diff --git a/docs/DLS.png b/docs/dls/DLS.png similarity index 100% rename from docs/DLS.png rename to docs/dls/DLS.png diff --git a/docs/getting-started/contributing-to-storybook.md b/docs/dls/contributing-to-storybook.md similarity index 100% rename from docs/getting-started/contributing-to-storybook.md rename to docs/dls/contributing-to-storybook.md diff --git a/docs/design.md b/docs/dls/design.md similarity index 100% rename from docs/design.md rename to docs/dls/design.md diff --git a/docs/designheader.png b/docs/dls/designheader.png similarity index 100% rename from docs/designheader.png rename to docs/dls/designheader.png diff --git a/docs/dls/figma.md b/docs/dls/figma.md new file mode 100644 index 0000000000..8fe6ea8d0f --- /dev/null +++ b/docs/dls/figma.md @@ -0,0 +1 @@ +We have a [Figma component library](https://www.figma.com/@backstage) that you can use to build your own plugins for Backstage. diff --git a/docs/getting-started/running-storybook.png b/docs/dls/running-storybook.png similarity index 100% rename from docs/getting-started/running-storybook.png rename to docs/dls/running-storybook.png diff --git a/docs/getting-started/storybook-page.png b/docs/dls/storybook-page.png similarity index 100% rename from docs/getting-started/storybook-page.png rename to docs/dls/storybook-page.png diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index a9fc17ce75..68cee2352c 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -311,3 +311,114 @@ component, but there will always be one ultimate owner. Apart from being a string, the software catalog leaves the format of this field open to implementers to choose. Most commonly, it is set to the ID or email of a group of people in an organizational structure. + +## Kind: Template + +Describes the following entity kind: + +| Field | Value | +| -------------------- | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `Kind: Templatekind` | `Template` | + +A Template describes a skeleton for use with the Scaffolder. It is used for +describing what templating library is supported, and also for documenting the +variables that the template requires using +[JSON Forms Schema](https://jsonforms.io/). + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: + Next.js application skeleton for creating isomorphic web applications. + tags: + - Recommended + - React +spec: + owner: web@example.com + templater: cookiecutter + type: website + path: '.' + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Description of the component +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `Template`, respectively. + +### `metadata.title` [required] + +The nice display name for the template as a string, e.g. `React SSR Template`. +This field is required as is used to reference the template to the user instead +of the `metadata.name` field. + +### `metadata.tags` [optional] + +A list of strings that can be associated with the template, e.g. +`['Recommended', 'React']`. + +This list will also be used in the frontend to display to the user so you can +potentially search and group templates by these tags. + +### `spec.type` [optional] + +The type of component as a string, e.g. `website`. This field is optional but +recommended. + +The software catalog accepts any type value, but an organisation should take +great care to establish a proper taxonomy for these. Tools including Backstage +itself may read this field and behave differently depending on its value. For +example, a website type component may present tooling in the Backstage interface +that is specific to just websites. + +The current set of well-known and common values for this field is: + +- `service` - a backend service, typically exposing an API +- `website` - a website +- `library` - a software library, such as an NPM module or a Java library + +### `spec.templater` [required] + +The templating library that is supported by the template skeleton as a string, +e.g `cookiecutter`. + +Different skeletons will use different templating syntax, so it's common that +the template will need to be run with a particular piece of software. + +This key will be used to identify the correct templater which is registered into +the `TemplatersBuilder`. + +The values which are available by default are: + +- `cookiecutter` - [cookiecutter](https://github.com/cookiecutter/cookiecutter). + +### `spec.path` [optional] + +The string location where the templater should be run if it is not on the same +level as the `template.yaml` definition, e.g. `./cookiecutter/skeleton`. + +This will set the `cwd` when running the templater to the folder path that you +specify relative to the `template.yaml` definition. + +This is also particularly useful when you have multiple template definitions in +the same repository but only a single `template.yaml` registered in backstage. diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md new file mode 100644 index 0000000000..4925f32e1a --- /dev/null +++ b/docs/features/software-catalog/index.md @@ -0,0 +1,26 @@ +# Backstage Service Catalog (alpha) + +## What is a Service Catalog? + +The Backstage Service Catalog — actually, a software catalog, since it includes +more than just services — is a centralized system that keeps track of ownership +and metadata for all the software in your ecosystem (services, websites, +libraries, data pipelines, etc). The catalog is built around the concept of +[metadata yaml files](../../architecture-decisions/adr002-default-catalog-file-format.md#format) +stored together with the code, which are then harvested and visualized in +Backstage. + +![service-catalog](https://backstage.io/blog/assets/6/header.png) + +We have also found that the service catalog is a great way to organise the +infrastructure tools you use to manage the software as well. This is how +Backstage creates one developer portal for all your tools. Rather than asking +teams to jump between different infrastructure UI’s (and incurring additional +cognitive overhead each time they make a context switch), most of these tools +can be organised around the entities in the catalog. + +## Using the Service Catalog + +TODO + +![](service-catalog-home.png) diff --git a/docs/features/software-catalog/populating.md b/docs/features/software-catalog/populating.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/features/software-catalog/service-catalog-home.png b/docs/features/software-catalog/service-catalog-home.png new file mode 100644 index 0000000000..742748632e Binary files /dev/null and b/docs/features/software-catalog/service-catalog-home.png differ diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md new file mode 100644 index 0000000000..e929ec4a97 --- /dev/null +++ b/docs/features/software-templates/adding-templates.md @@ -0,0 +1,102 @@ +# Adding your own Templates + +Templates are stored in the **Service Catalog** under a kind `Template`. The +minimum that the a template skeleton needs is a `template.yaml` but it would be +good to also have some files in there that can be templated in. + +A simple `template.yaml` definition might look something like this: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + # unique name per namespace for the template + name: react-ssr-template + # title of the template + title: React SSR Template + # a description of the template + description: Next.js application skeleton for creating isomorphic web applications. + # some tags to display in the frontend + tags: + - Recommended + - React +spec: + # which templater key to use in the templaters builder + templater: cookiecutter + # what does this template create + type: website + # if the template is not in the current directory where this definition is kept then specfiy + path: './template' + # the schema for the form which is displayed in the frontend. + # should follow JSON schema for forms: https://jsonforms.io/ + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Description of the component +``` + +[Template Entity](../software-catalog/descriptor-format.md#kind-template) +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. + +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. + +For loading from a file the following command should work when the backend is +running: + +```sh +curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"file\", \"target\": \"${YOUR PATH HERE}/template.yaml\"}" +``` + +If loading from a git location, you can run the following + +```sh +curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"github\", \"target\": \"https://${YOUR GITHUB REPO}blob/master/${PATH TO FOLDER}/template.yaml\"}" +``` + +This should then have added the catalog, and also should now be listed under the +create page at http://localhost:3000/create. + +Alternatively, if you want to get setup with some mock templates that are +already provided for you, you can run the following to load those templates: + +``` +yarn lerna run mock-data +``` + +The `type` field which is chosen in the request to add the `template.yaml` to +the Service Catalog here, will be come the `PreparerKey` which will be used to +select the `Preparer` when creating a job. + +### Adding form values in the Scaffolder Wizard + +The `spec.schema` property in the +[Template Entity](../software-catalog/descriptor-format.md#kind-template) is a +`yaml` version of the JSON Form Schema standard. + +Here you can define the key/values and then the wizard will convert this to a +form for the user to fill in when your template is selected. + +You can find out much more about the standard and how to use it here: +https://jsonforms.io diff --git a/docs/features/software-templates/assets/added-to-the-catalog-list.png b/docs/features/software-templates/assets/added-to-the-catalog-list.png new file mode 100644 index 0000000000..4b544e51c1 Binary files /dev/null and b/docs/features/software-templates/assets/added-to-the-catalog-list.png differ diff --git a/docs/features/software-templates/assets/complete.png b/docs/features/software-templates/assets/complete.png new file mode 100644 index 0000000000..eee14fae0e Binary files /dev/null and b/docs/features/software-templates/assets/complete.png differ diff --git a/docs/features/software-templates/assets/create.png b/docs/features/software-templates/assets/create.png new file mode 100644 index 0000000000..8a2e92b9f4 Binary files /dev/null and b/docs/features/software-templates/assets/create.png differ diff --git a/docs/features/software-templates/assets/failed.png b/docs/features/software-templates/assets/failed.png new file mode 100644 index 0000000000..bca8c72d6a Binary files /dev/null and b/docs/features/software-templates/assets/failed.png differ diff --git a/docs/features/software-templates/assets/go-to-catalog.png b/docs/features/software-templates/assets/go-to-catalog.png new file mode 100644 index 0000000000..ae16230a02 Binary files /dev/null and b/docs/features/software-templates/assets/go-to-catalog.png differ diff --git a/docs/features/software-templates/assets/running.png b/docs/features/software-templates/assets/running.png new file mode 100644 index 0000000000..208376e059 Binary files /dev/null and b/docs/features/software-templates/assets/running.png differ diff --git a/docs/features/software-templates/assets/template-picked-2.png b/docs/features/software-templates/assets/template-picked-2.png new file mode 100644 index 0000000000..685e356ee8 Binary files /dev/null and b/docs/features/software-templates/assets/template-picked-2.png differ diff --git a/docs/features/software-templates/assets/template-picked.png b/docs/features/software-templates/assets/template-picked.png new file mode 100644 index 0000000000..1094acec4a Binary files /dev/null and b/docs/features/software-templates/assets/template-picked.png differ diff --git a/docs/features/software-templates/extending/create-your-own-preparer.md b/docs/features/software-templates/extending/create-your-own-preparer.md new file mode 100644 index 0000000000..4e45bef535 --- /dev/null +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -0,0 +1,97 @@ +# Create your own Preparer + +Preparers are responsible for reading the location of the definition of a +[Template Entity](../../software-catalog/descriptor-format.md#kind-template) and +making a temporary folder with the contents of the selected skeleton. + +Currently, we provide two different providers that can parse two different +location protocols: + +- `file://` +- `github://` + +These two are added to the `PreparersBuilder` and then passed into the +`createRouter` function of the `@spotify/plugin-scaffolder-backend` + +An full example backend can be found +[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +but it looks something like the following + +```ts +import { + createRouter, + FilePreparer, + GithubPreparer, + Preparers, +} from '@backstage/plugin-scaffolder-backend'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + const preparers = new Preparers(); + + const filePreparer = new FilePreparer(); + const githubPreparer = new GithubPreparer(); + + preparers.register('file', filePreparer); + preparers.register('github', githubPreparer); + + return await createRouter({ + preparers, + templaters, + logger, + }); +} +``` + +As you can see in the above code, a `PreparerBuilder` is created, and then two +of the `preparers` are registered with the different protocols that they accept. + +The `protocol` is set on the +[Template Entity](../../software-catalog/descriptor-format.md#kind-template) +when added to the service catalog. You can see more about this `PreparerKey` +here in [Register your own template](../adding-templates.md) + +**note:** Currently the catalog supports loading definitions from Github + Local +Files, which translate into the two `PreparerKeys` `file` and `github`. 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. + +### Creating your own Preparer to add to the `PreparerBuilder` + +All preparers need to implement the `PreparerBase` type. + +That type looks like the following: + +```ts +export type PreparerBase = { + prepare( + template: TemplateEntityV1alpha1, + opts: { logger: Logger }, + ): Promise; +}; +``` + +The `prepare` function will be given the +[Template Entity](../../software-catalog/descriptor-format.md#kind-template) +along with the source of where the `template.yaml` was loaded from under the +`metedata.annotations.managed-by-location` property. + +Now it's up to you to implement a function which can go and fetch the skeleton +and put the contents into a temporary directory and return that directory path. + +Some good examples exist here: + +- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts + +### Registerinng your own Preparer + +You can register the preparer that you have created with the `PreparerBuilder` +by using the `PreparerKey` from the Catalog, for example like this: + +```ts +const preparers = new Preparers(); +preparers.register('gcs', new GoogleCloudStoragePreparer()); +``` + +And then pass this in to the `createRouter` function. diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md new file mode 100644 index 0000000000..8baf604e01 --- /dev/null +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -0,0 +1,92 @@ +# Create your own Publisher + +Publishers are responsible for pushing and storing the templated skeleton after +the values have been templated by the `Templater`. See +[Create your own templater](./create-your-own-templater.md) for more info. + +They recieve a directory or location where the templater has sucessfully run on, +and is now ready to store somewhere. They also get given some other options +which are sent from the frontend, such as the `storePath` which is a string of +where the frontend thinks we should save this templated folder. + +Currently we provide the following `publishers`: + +- `github` + +This publisher is passed through to the `createRouter` function of the +`@spotify/plugin-scaffolder-backend`. Currently only one publisher is supported, +but PR's are always welcome. + +An full example backend can be found +[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +but it looks something like the following + +```ts +import { + createRouter, + GithubPublisher, +} from '@backstage/plugin-scaffolder-backend'; +import { Octokit } from '@octokit/rest'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); + const publisher = new GithubPublisher({ client: githubClient }); + + return await createRouter({ + publisher, + logger, + }); +} +``` + +The publisher will always be called with the location from the selected +`Preparer`. + +### Create your own Publisher and register it with the Scaffolder + +All `publishers` need to implement the `PublisherBase` type. + +That type looks like the following: + +```ts +export type PublisherBase = { + publish(opts: { + entity: TemplateEntityV1alpha1; + values: RequiredTemplateValues & Record; + directory: string; + }): Promise<{ remoteUrl: string }>; +}; +``` + +The `publisher` function will be called with an `options` object which contains +the following: + +- `entity` - the + [Template Entity](../../software-catalog/descriptor-format.md#kind-template) + which is currently being scaffolded +- `values` - a json object which will resemble the `spec.schema` from the + [Template Entity](../../software-catalog/descriptor-format.md#kind-template) + which is defined here under spec.schema`. More info can be found here + [Register your own template](../adding-templates.md#adding-form-values-in-the-scaffolder-wizard) +- `directory` - a string containing the returned path from the `templater`. See + more information here in + [Create your own templater](./create-your-own-templater.md) + +Now it's up to you to implement the `publish` function and return +`{ remoteUrl: string }` which can be used to identify the finished product. + +Some good examples exist here: + +- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts + +### Registering your own Publisher + +Currently, we only support one `publisher` (PR's welcome), but you can register +any single `publisher` with the `createRouter`. + +```ts +return await createRouter({ + publisher: new MyGitlabPublisher(), +}); +``` diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md new file mode 100644 index 0000000000..947190c5e5 --- /dev/null +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -0,0 +1,146 @@ +# Creating your own Templater + +Templaters are responsible for taking the directory path for the skeleton +returned by the preparers, and then executing the templating command on top of +the file and returning the completed template path. This may or may not be the +same directory as the input directory. + +They also recieve additional values from the frontend, which can be used to +interpolate into the skeleton files. + +Currently we provide the following templaters: + +- `cookiecutter` + +This templater is added the `TemplaterBuilder` and then passed into the +`createRouter` function of the `@spotify/plugin-scaffolder-backend` + +An full example backend can be found +[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +but it looks something like the following + +```ts +import { + CookieCutter, + createRouter, + Templaters, +} from '@backstage/plugin-scaffolder-backend'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + const templaters = new Templaters(); + const cookiecutterTemplater = new CookieCutter(); + templaters.register('cookiecutter', cookiecutterTemplater); + + return await createRouter({ + templaters, + }); +} +``` + +As you can see in the above code a `TemplaterBuilder` is created and the default +`cookiecutter` `templater` is registered under the key `cookicutter`. + +This `TemplaterKey` is used to select the correct templater from the +`spec.templater` in the +[Template Entity](../../software-catalog/descriptor-format.md#kind-template). + +If you wish to add a new templater you'll need to register it with the +`TemplaterBuilder`. + +### Creating your own Templater to add to the `TemplaterBuilder` + +All templaters need to implement the `TemplaterBase` type. + +That type looks like the following: + +```ts +export type TemplaterRunOptions = { + directory: string; + values: RequiredTemplateValues & Record; + logStream?: Writable; + dockerClient: Docker; +}; + +export type TemplaterBase = { + run(opts: TemplaterRunOptions): Promise; +}; +``` + +The `run` function will be given a `TemplaterRunOptions` object which is as +follows: + +- `directory`- the skeleton directory returned from the `Preparer`, more info at + [Create your own preparer](./create-your-own-preparer.md). +- `values` - a json object which will resemble the `spec.schema` from the + [Template Entity](../../software-catalog/descriptor-format.md#kind-template) + which is defined here under spec.schema`. More info can be found here + [Register your own template](../adding-templates.md#adding-form-values-in-the-scaffolder-wizard) +- `logStream` - a stream that you can write to for displaying in the frontend. +- `dockerClient` - a [dockerode](https://github.com/apocas/dockerode) client to + be able to run docker containers. + +_note_ currently the templaters that we provide are basically docker action +containers that are run on top of the skeleton folder. This keeps dependencies +to a minimal for running backstage scaffolder, but you don't /have/ to use +docker. You could create your own templater that spins up an EC2 instance and +downloads the folder and does everything using an AMI if you want. It's entirely +up to you! + +Now it's up to you to implement the `run` function, and then return a +`TemplaterRunResult` which is `{ resultDir: string }`. + +Some good examples exist here: + +- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts + +### Registering your own Templater + +If you try to process a +[Template Entity](../../software-catalog/descriptor-format.md#kind-template) +with a new `spec.templater` value, you'll need to register that with the +`TemplaterBuilder`. + +For example let's say you have the following +[Template Entity](../../software-catalog/descriptor-format.md#kind-template): + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: + Next.js application skeleton for creating isomorphic web applications. + tags: + - Recommended + - React +spec: + owner: web@example.com + templater: handlebars + type: website + path: '.' + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Description of the component +``` + +You see that the `spec.templater` is set as `handlebars`, you'll need to +register this with the `TemplaterBuilder` like so: + +```ts +const templaters = new Templaters(); +templaters.register('handlebars', new HandlebarsTemplater()); +``` + +And then pass this in to the `createRouter` function. diff --git a/docs/features/software-templates/extending/index.md b/docs/features/software-templates/extending/index.md new file mode 100644 index 0000000000..a55128c57d --- /dev/null +++ b/docs/features/software-templates/extending/index.md @@ -0,0 +1,87 @@ +## Extending the Scaffolder + +Welcome. Take a seat. You're at the Scaffolder Documentation. + +So - You wanna create stuff inside your company from some prebaked templates? +You're at the right place. + +This guide is gonna take you through how the Scaffolder in Backstage works. +We'll dive into some jargon and run through whats going on in the backend to be +able to create these templates. There's also more guides that you might find +useful at the bottom of this document. At it's core, theres 3 simple stages. + +1. Pick a skeleton +2. Template some variables into the skeleton +3. Send the templated skeleton somewhere + +These three steps are translated to the folllowing stages under the hood in the +scaffolder that you will need to know: + +1. Prepare +2. Template +3. Publish + +Each of these steps can be configured for your own use case, but we provide some +sensible defaults too. + +Lets dive a little deeper into these phases. + +### Glossary and Jargon + +**Preparer** - The preparer is responsible for fetching the skeleton code and +placing it into a directory and then will return that directory. It is +registered with the `Preparers` with a particular type, which is then used in +the router to pick the correct `Preparer` to run for the `Template` entity. + +**Templater** - The templater is responsible for actually running the chosen +templater on top of the previously returned temporary directory from the +**Preprarer**. We advise making these docker containers as it can keep all +dependencies, for example Cookiecutter, self contained and not a dependency on +the host machine. + +**Publisher** - The publisher is responsible for taking the finished directory, +and publishing it to a remote registry. This could be a Git repository or +something similar. Right now, the scaffolder only supports one publishing method +for the entire lifecycle, but it could be configured from the frontend and +passed through to the scaffolder backend. + +### How it works + +The main of the heavy lifting is done in the +[router.ts](https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/service/router.ts#L93) +file in the `scaffolder-backend` plugin. + +There are 2 routes defined in the router. `POST /v1/jobs` and +`GET /v1/job/:jobId` + +To create a scaffolding job, a JSON object containing the +[Template Entity](../../software-catalog/descriptor-format.md#kind-template) + +additional templating values must be posted as the post body. + +```js +{ + "template": { + "apiVersion": "backstage/v1alpha1", + "kind": "Template", + // more stuff here + }, + "values": { + "component_id": "test", + "description": "somethingelse" + } +} +``` + +The values should represent something that is valid with the `schema` part of +the [Template Entity](../../software-catalog/descriptor-format.md#kind-template) + +Once that has been posted, a job will be setup with different stages. And the +job processor will complete each stage before moving onto the next stage, whilst +collecting logs and mutating the running job. + +Here's some futher reading that you might find useful: + +- [Adding your own Template](../adding-templates.md) +- [Creating your own Templater](./create-your-own-templater.md) +- [Creating your own Publisher](./create-your-own-publisher.md) +- [Creating your own Preparer](./create-your-own-preparer.md) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md new file mode 100644 index 0000000000..c7358518cd --- /dev/null +++ b/docs/features/software-templates/index.md @@ -0,0 +1,59 @@ +# Software Templates + +The Software Templates part of Backstage is a tool that can help you create +Components inside Backstage. It by default has the ability to load skeletons of +code, template in some variables and then publish the template to some location +like GitHub. + +### Getting Started + +The Software Templates are available under `/create`, and if you've followed +[Getting Started with Backstage](../../getting-started), you should be able to +reach `http://localhost:3000/create`. + +You should get something that looks similar to this: + +![Create Image](./assets/create.png) + +### Choose a template + +When you select a template that you want to create, you'll be taken to the next +page which may or may not look different for each template. Each template can +ask for different input variables, and they are then passed to the templater +internally. + +![Enter some variables](./assets/template-picked.png) + +After filling in these variables, you'll get some more fields to fill out which +are required for backstage usage. The owner, which is a `user` in the backstage +system, and the `storePath` which right now must be a Github Organisation and a +non-existing github repository name in the format `organistaion/reponame`. + +![Enter backstage vars](./assets/template-picked-2.png) + +### Run! + +Once you've entered values and confirmed, you'll then get a modal with live +progress of what is currently happening with the creation of your template. + +![Templating Running](./assets/running.png) + +It shouldn't take too long, and you'll have a success screen! + +![Templating Complete](./assets/complete.png) + +If it fails, you'll be able to click on each section to get the log from the +step that failed which can be helpful to debug. + +![Templating failed](./assets/failed.png) + +### View Component in Catalog + +When it's been created you'll see the `View in Catalog` button, which will take +you to the registered component in the catalog: + +![Catalog](./assets/go-to-catalog.png) + +And then you'll also be able to see it in the Catalog View table + +![Catalog](./assets/added-to-the-catalog-list.png) diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 4514b4c106..ee9a40c983 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -1 +1,29 @@ -# FAQ +# TechDocs FAQ + +This page answer frequently asked questions about [TechDocs]. + +#### Technology + +- [What static site generator is TechDocs using?](./#what-static-site-generator-is-techdocs-using) +- [What is the mkdocs-techdocs-core plugin?](./#what-is-the-mkdocs-techdocs-core-plugin) + +## What static site generator is TechDocs using? + +TechDocs is using [MkDocs](https://www.mkdocs.org/) to build project +doucmentation under the hood. Documentation built with the +[techdocs-container](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/README.md) +is using the MkDocs Material Theme. + +## What is the mkdocs-techdocs-core plugin? + +The +[mkdocs-techdocs-core](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/techdocs-core/README.md) +package is a MkDocs Plugin that works like a wrapper around multiple MkDocs +plugins (e.g. +[MkDocs Monorepo Plugin](https://github.com/spotify/mkdocs-monorepo-plugin)) as +well as a selection of Python Markdown extensions that TechDocs supports. + +_Add a question that you think others might be interested in? Edit the file +[here](https://github.com/spotify/backstage/edit/master/docs/features/techdocs/FAQ.md)._ + +[techdocs]: README.md diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 338d4af144..b6a4d82b92 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -2,17 +2,41 @@ ## What is it? -Intro, backstory, etc. + + +Wait, what is TechDocs? TechDocs is Spotify’s homegrown docs-like-code solution +built directly into Backstage. Today, it is now one of the core products in +Spotify’s developer experience offering with 2,400+ documentation sites and +1,000+ engineers using it daily. + +## Features + +- A centralized place to discover documentation. + +- A clear end-to-end docs-like-code solution. (_Coming soon in V.1_) + +- A tightly coupled feedback loop with the developer workflow. (_Coming soon in + V.2_) + +- A developer ecosystem for creating extensions. (_Coming soon in V.2_) + +## Project roadmap + +| Version | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| [TechDocs V.0 ✅][v0] | Read docs in Backstage - Enable anyone to get a reader experience working in Backstage. | +| [TechDocs V.1 🚧][v1] | TechDocs end to end - First and minimum release of TechDocs that you can use end to end - and contribute to. | +| [TechDocs V.2 🔮⌛][v2] | Widget Architecture - TechDocs widget architecture available, so the community can create their own customized features. | + +[v0]: https://github.com/spotify/backstage/milestone/15 +[v1]: https://github.com/spotify/backstage/milestone/16 +[v2]: https://github.com/spotify/backstage/milestone/17 ## Structure - [Getting Started] - [Concepts] -- [Reading Documentation] -- [Writing Documentation] -- [Publishing Documentation] -- [Contributing] -- [Debugging] +- [Creating and Publishing Documentation] - [FAQ] ## Tech Stack @@ -26,11 +50,7 @@ Intro, backstory, etc. [getting started]: getting-started.md [concepts]: concepts.md -[reading documentation]: reading-documentation.md -[writing documentation]: writing-documentation.md -[publishing documentation]: publishing-documentation.md -[contributing]: contributing.md -[debugging]: debugging.md +[creating and publishing documentation]: creating-and-publishing.md [faq]: FAQ.md 'Frequently asked questions' [techdocs/frontend]: https://github.com/spotify/backstage/blob/master/plugins/techdocs diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index 74d42e13cd..d0e3c3c0ea 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -1 +1,53 @@ # Concepts + +This page describes concepts that has been introduced with Spotify's +docs-like-code solution in Backstage. + +### TechDocs Core Plugin + +The TechDocs Core Plugin is a MkDocs plugin created as a wrapper around multiple +MkDocs plugins and Python Markdown extensions to standardize the configuration +of MkDocs used for TechDocs. + +[TechDocs Core](../../../packages/techdocs-container/techdocs-core/README.md) + +### TechDocs container + +The TechDocs container is a Docker container available at +[DockerHub](https://hub.docker.com/r/spotify/techdocs). It builds static HTML +pages, including stylesheets and scripts from Python flavored Markdown, through +MkDocs. + +[TechDocs Container](../../../packages/techdocs-container/README.md) + +### TechDocs publisher (Coming Soon) + +### TechDocs CLI + +The TechDocs CLI was created to make it easy to write, generate and preview +documentation for publishing. Currently it mostly acts as a wrapper around the +TechDocs container and provides a easy to use interface for our docker +container. + +[TechDocs CLI](../../../packages/techdocs-cli/README.md) + +### TechDocs Reader + +Documentation generated by TechDocs is generated as static html sites. The +TechDocs Reader was therefore created to be able to integrate pre-generated html +sites with the Backstage UI. + +The TechDocs Reader purpose is also to open up the opportunity to integrate +TechDocs widgets for a customized full-featured TechDocs experience. +([Coming Soon V.2](https://github.com/spotify/backstage/milestone/17)) + +[TechDocs Reader](../../../plugins/techdocs/src/reader/README.md) + +### Transformers + +Transformers is different pieces of functionality used inside the TechDocs +Reader. The reason to why transformers were introduced is to provide a way to +transform the html content on pre and post render. (e.g. rewrite docs links or +modify css) + +[Transformers API docs](../../../plugins/techdocs/src/reader/transformers/README.md) diff --git a/docs/features/techdocs/contributing.md b/docs/features/techdocs/contributing.md deleted file mode 100644 index 854139a319..0000000000 --- a/docs/features/techdocs/contributing.md +++ /dev/null @@ -1 +0,0 @@ -# Contributing diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md new file mode 100644 index 0000000000..0ac4f054d7 --- /dev/null +++ b/docs/features/techdocs/creating-and-publishing.md @@ -0,0 +1,134 @@ +# Creating and publishing your docs + +This section will guide you through: + +- Creating a basic setup for your documentation +- Writing and previewing your documentation in a local Backstage environment +- Creating a build ready for publication +- Publishing your documentation and making your Backstage instance read from + your published docs. + +## Prerequisities + +- [Docker](https://docs.docker.com/get-docker/) +- Static file hosting +- A working Backstage instance with TechDocs installed + [TechDocs getting started](getting-started.md) + +## Create a basic documentation setup + +Create a directory that contains your documentation. Inside this directory you +should create a file called `mkdocs.yml`. As an example you can create a +directory called `hello-docs` in your home directory (also known as `~`). Below +is a basic example of how it could look. + +The `~/hello-docs/mkdocs.yml` file should have the following content: + +```yaml +site_name: 'example-docs' + +nav: + - Home: index.md + +plugins: + - techdocs-core +``` + +And then the `~/hello-docs/docs/index.md` should have the following content: + +```md +# example docs + +This is a basic example of documentation. +``` + +## Writing and previewing your documentation + +Using the `techdocs-cli` you can preview your docs inside a local Backstage +instance and get automatic recompilation on changes. This is useful for when you +want to write your documentation. + +To do this you can run: + +```bash +cd ~/hello-docs/ +npx techdocs-cli serve +``` + +## Build production ready documentation + +To get a build suitable for publication you can build your docs using the +`spotify/techdocs` container. + +```bash +cd ~/hello-docs/ +docker run -it -w /content -v $(pwd):/content spotify/techdocs build +``` + +You should now have a folder called `~/hello-docs/site/`. + +## Deploy to a file server + +In order to serve documentation to TechDocs, our Backstage plugin needs to +download the HTML rendered from the +[Create documentation](#create-documentation) step above. This will likely exist +on an external file server, or a storage solution such as Google Cloud Storage. + +When deploying documentation, it should be deployed on that file server / +storage solution with the following convention: `{id}/{file}`. For example, if +we wanted to upload the `getting-started/index.html` file for the `backstage` +documentation site, we would upload it to our file server as +`backstage/getting-started/index.html`. + +To explain further how this would look like for multiple documentation sites, +take a look at this example file tree that would be represented on your file +server: + +```md +/backstage/index.html /backstage/getting-started/index.html +/backstage/contributing/index.html /mkdocs/index.html +/mkdocs/plugin-development/index.html +/mkdocs/plugin-development/debugging/index.html +``` + +In this file tree, we have two documentation sites available: `backstage` and +`mkdocs`. Each of them expose several pages. Let's say both of these are hosted +on `http://example.com` as the server URL. + +When you configure the TechDocs plugin in Backstage to use `http://example.com` +as the file server / storage solution, it will translate the following URLs to +the file server: + +| Backstage URL | File Server URL | +| --------------------------------------------------------- | ------------------------------------------------------- | +| https://demo.backstage.io/docs/backstage/ | http://example.com/backstage/index.html | +| https://demo.backstage.io/docs/mkdocs/plugin-development/ | http://example.com/mkdocs/plugin-development/index.html | + +Then deploying new sites is easy. It's as simple as copying over the `site/` +folder produced in the [Create documentation](#create-documentation) step above +and copying it over to the file server / storage solution under the ID of the +documentation site. It will then become immediately available in Backstage under +the same ID as you can see in the table above. + +So, if the URL to your file server is `http://example.com/`, your +`~/hello-docs/site` folder containing the documentation should be accessible at +`http://example.com/hello-docs/`. + +## Configure TechDocs to read from file server + +In order for Backstage to show your documentation, it needs to know where you +uploaded it. + +Make sure you have Backstage set up using +[TechDocs getting started](getting-started.md) + +To point Backstage to your docs storage, add or change the following lines in +your Backstage `app-config.yaml`: + +```yaml +techdocs: + storageUrl: http://example.com +``` + +You can now start Backstage using `yarn start` and open up your browser at +`http://localhost:3000/docs/hello-docs` to view your docs. diff --git a/docs/features/techdocs/debugging.md b/docs/features/techdocs/debugging.md deleted file mode 100644 index 1e584c954d..0000000000 --- a/docs/features/techdocs/debugging.md +++ /dev/null @@ -1 +0,0 @@ -# Debugging diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index bad55622f9..2ea8fc7fa9 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -1 +1,109 @@ # Getting Started + +> TechDocs is not feature complete and currently you can't set up a complete +> end-to-end working TechDocs plugin without customizing the plugin itself. + +> With TechDocs V.0 you can expect a demonstration of how to integrate docs into +> Backstage. Currently it can create docs using +> [mkdocs](https://www.mkdocs.org/), as well as reading published docs. If you +> publish generated docs and passing in a storageUrl in your `app-config.yaml` +> you can view it in Backstage by going to +> `http://localhost:3000/docs/`. + +Getting started with TechDocs is easy. TechDocs functions as a plugin to +Backstage, why you will need to use Backstage to use TechDocs. + +## What is Backstage? + +Backstage is an open platform for building developer portals. It’s based on the +developer portal we’ve been using internally at Spotify for over four years. +[Read more](https://github.com/spotify/backstage). + +## Prerequisities + +In order to use Backstage and TechDocs, you will need to have the following +installed: + +- [Node.js](https://nodejs.org) Active LTS (long term support), currently v12 +- [Yarn](https://yarnpkg.com/getting-started/install) + +## Creating a new Backstage app + +> If you have already created a Backstage application for this purpose, jump to +> [Installing TechDocs](#installing-techdocs), otherwise complete this step. + +To create a new Backstage application for us to set up TechDocs, you will need +to run the following command: + +```bash +npx @backstage/cli create-app +``` + +You will then be prompted to enter a name for your application. Once you do so, +this will create a new Backstage application for you in a new folder. For +example, if we chose the name `hello-world` for our application, it would create +a new `hello-world` folder containing our new Backstage application. + +## Installing TechDocs + +Inside of our new Backstage application, TechDocs is not provided by default. +For this reason we will need to manually set up TechDocs. It should take less +than a minute. + +### Adding the package + +We will need to add our plugin to your Backstage application. To do so, you can +navigate to your new Backstage application folder and then run a single command +to install TechDocs. + +```bash +cd hello-world/ +``` + +Then you need to navigate to your `packages/app` folder to install TechDocs: + +```bash +cd packages/app +yarn add @backstage/plugin-techdocs +``` + +After a short while, it should successfully install the TechDocs plugin. Now we +just need to set up some basic configuration! + +Enter the following command: + +```bash +yarn install +``` + +Add this to `packages/app/src/plugins.ts`: + +```typescript +export { plugin as TechDocs } from '@backstage/plugin-techdocs'; +``` + +### Setting the configuration + +TechDocs allows for configuration of the docs storage URL through your +app-config file. The URL provided here is demo docs used to testing. + +To use the demo docs, add the following lines to `app-config.yaml`: + +```yaml +techdocs: + storageUrl: https://techdocs-mock-sites.storage.googleapis.com +``` + +## Run Backstage Locally + +Change folder to your Backstage application root. + +```bash +yarn start +``` + +Open browser at [http://localhost:3000/docs/](http://localhost:3000/docs/) + +## Extra Reading + +[Back to Docs](README.md) diff --git a/docs/features/techdocs/publishing-documentation.md b/docs/features/techdocs/publishing-documentation.md deleted file mode 100644 index 9d1bac187a..0000000000 --- a/docs/features/techdocs/publishing-documentation.md +++ /dev/null @@ -1 +0,0 @@ -# Publishing Documentation diff --git a/docs/features/techdocs/reading-documentation.md b/docs/features/techdocs/reading-documentation.md deleted file mode 100644 index f872ed5df8..0000000000 --- a/docs/features/techdocs/reading-documentation.md +++ /dev/null @@ -1 +0,0 @@ -# Reading Documentation diff --git a/docs/features/techdocs/writing-documentation.md b/docs/features/techdocs/writing-documentation.md deleted file mode 100644 index 5fea01bcad..0000000000 --- a/docs/features/techdocs/writing-documentation.md +++ /dev/null @@ -1 +0,0 @@ -# Writing Documentation diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/create-an-app.md b/docs/getting-started/create-an-app.md similarity index 100% rename from docs/create-an-app.md rename to docs/getting-started/create-an-app.md diff --git a/docs/create-app_output.png b/docs/getting-started/create-app_output.png similarity index 100% rename from docs/create-app_output.png rename to docs/getting-started/create-app_output.png diff --git a/docs/getting-started/deployment-k8s.md b/docs/getting-started/deployment-k8s.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md new file mode 100644 index 0000000000..26c70acac4 --- /dev/null +++ b/docs/getting-started/deployment-other.md @@ -0,0 +1,27 @@ +# Deployment (Other) + +## Deploying Locally + +### Try on Docker + +Run the following commands if you have Docker environment + +```bash +$ yarn docker-build +$ docker run --rm -it -p 80:80 spotify/backstage +``` + +Then open http://localhost/ on your browser. + +### Running with `docker-compose` + +Run the following commands if you have docker and docker-compose for a full +example, with the example backend also deployed. + +```bash +$ yarn docker-build:all +$ docker-compose up +``` + +Then open http://localhost:3000 on your browser to see the example app with an +example backend. diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 7f650b27e6..e10ee4b24a 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -50,7 +50,8 @@ system resources and slow things down. ## Package Scripts -There are many commands to be found in the root [package.json](package.json), +There are many commands to be found in the root +[package.json](https://github.com/spotify/backstage/blob/master/package.json), here are some useful ones: ```python @@ -73,26 +74,15 @@ yarn test:all # test all packages yarn clean # Remove all output folders and @backstage/cli cache -yarn bundle # Build a production bundle of the example app - yarn diff # Make sure all plugins are up to date with the latest plugin template yarn create-plugin # Create a new plugin ``` -### (Optional)Try on Docker +> See +> [package.json](https://github.com/spotify/backstage/blob/master/package.json) +> for other yarn commands/options. -Run the following commands if you have Docker environment +[Next Step - Create a Backstage plugin](../plugins/create-a-plugin.md) -```bash -$ yarn docker-build -$ docker run --rm -it -p 80:80 spotify/backstage -``` - -Then open http://localhost/ on your browser. - -> See [package.json](/package.json) for other yarn commands/options. - -[Next Step - Create a Backstage plugin](create-a-plugin.md) - -[Back to Docs](README.md) +[Back to Docs](../README.md) diff --git a/docs/getting-started/README.md b/docs/getting-started/index.md similarity index 92% rename from docs/getting-started/README.md rename to docs/getting-started/index.md index 5e404d4cb6..2770b7a154 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/index.md @@ -62,9 +62,8 @@ collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins. - [Development Environment](development-environment.md) -- [Create a Backstage Plugin](create-a-plugin.md) -- [Structure of a Plugin](structure-of-a-plugin.md) -- [Utility APIs](utility-apis.md) -- Using Backstage components (TODO) +- [Create a Backstage Plugin](../plugins/create-a-plugin.md) +- [Structure of a Plugin](../plugins/structure-of-a-plugin.md) +- [Utility APIs](../api/utility-apis.md) [Back to Docs](../README.md) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md new file mode 100644 index 0000000000..9093e98e2e --- /dev/null +++ b/docs/overview/architecture-overview.md @@ -0,0 +1,190 @@ +# Typical Backstage architecture + +The following diagram shows how Backstage might look when deployed inside a +company which uses the Tech Radar plugin, the Lighthouse plugin, the Circle CI +plugin and the service catalog. + +There are 3 main components in this architecture: + +1. The core Backstage UI +2. The UI plugins and their backing services +3. Databases + +Running this architecture in a real environment typically involves +containerising the components. Various commands are provided for accomplishing +this. + +![The architecture of a basic Backstage application](./architecture-overview/backstage-typical-architecture.png) + +# The UI + +The UI is a thin, client-side wrapper around a set of plugins. It provides some +core UI components and libraries for shared activities such as config +management. [[live demo](https://backstage-demo.roadie.io/)] + +![UI with different components highlighted](./architecture-overview/core-vs-plugin-components-highlighted.png) + +Each plugin typically makes itself available in the UI on a dedicated URL. For +example, the lighthouse plugin is registered with the UI on `/lighthouse`. +[[live demo](https://backstage-demo.roadie.io/lighthouse)] + +![The lighthouse plugin UI](./architecture-overview/lighthouse-plugin.png) + +The Circle CI plugin is available on `/circleci`. + +![Circle CI Plugin UI](./architecture-overview/circle-ci.png) + +# Plugins and plugin backends + +Each plugin is a client side application which mounts itself on the UI. Plugins +are written in TypeScript or JavaScript. They each live in their own directory +in `backstage/plugins`. For example, the source code for the lighthouse plugin +is available at +[backstage/plugins/lighthouse](https://github.com/spotify/backstage/tree/master/plugins/lighthouse). + +## Installing plugins + +Plugins are typically loaded by the UI in your Backstage applications +`plugins.ts` file. For example, +[here](https://github.com/spotify/backstage/blob/master/packages/app/src/plugins.ts) +is that file in the Backstage sample app. + +Plugins can be enabled, and passed configuration in `apis.ts`. For example, +[here](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts) +is that file in the Backstage sample app. + +This is how the lighthouse plugin would be enabled in a typical Backstage +application: + +```tsx +import { ApiHolder, ApiRegistry } from '@backstage/core'; +import { + lighthouseApiRef, + LighthouseRestApi, +} from '@backstage/plugin-lighthouse'; + +const builder = ApiRegistry.builder(); + +export const lighthouseApi = new LighthouseRestApi(/* URL of the lighthouse microservice! */); +builder.add(lighthouseApiRef, lighthouseApi); + +export default builder.build() as ApiHolder; +``` + +As of this moment, there is no config based install procedure for plugins. Some +code changes are required. + +## Plugin architecture + +Architecturally, plugins can take three forms: + +1. Standalone +2. Service backed +3. Third-party backed + +### Standalone plugins + +Standalone plugins run entirely in the browser. +[The tech radar plugin](https://backstage-demo.roadie.io/tech-radar), for +example, simply renders hard-coded information. It doesn't make any API requests +to other services. + +![tech radar plugin ui](./architecture-overview/tech-radar-plugin.png) + +The architecture of the Tech Radar installed into a Backstage app is very +simple. + +![ui and tech radar plugin connected together](./architecture-overview/tech-radar-plugin-architecture.png) + +### Service backed plugins + +Service backed plugins make API requests to a service which is within the +purview of the organisation running Backstage. + +The lighthouse plugin, for example, makes requests to the +[lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). +The lighthouse-audit-service is a microservice which runs a copy of Google's +[Lighthouse library](https://github.com/GoogleChrome/lighthouse/) and stores the +results in a PostgreSQL database. + +Its architecture looks like this: + +![lighthouse plugin backed to microservice and database](./architecture-overview/lighthouse-plugin-architecture.png) + +The service catalog in Backstage is another example of a service backed plugin. +It retrieves a list of services, or "entities", from the Backstage Backend +service and renders them in a table for the user. + +### Third-party backed plugins + +Third-party backed plugins are similar to service backed plugins. The main +difference is that the service which backs the plugin is hosted outside of the +ecosystem of the company hosting Backstage. + +The Circle CI plugin is an example of a third-party backed plugin. Circle CI is +a SaaS service which can be used without any knowledge of Backstage. It has an +API which a Backstage plugin consumes to display content. + +Requests which go to Circle CI from the users browser are passed through a proxy +service that Backstage provides. Without this, the requests would be blocked by +Cross Origin Resource Sharing policies which prevent a browser page served at +[https://example.com](https://example.com) from serving resources hosted at +https://circleci.com. + +![CircleCi plugin talking to proxy talking to SaaS Circle CI](./architecture-overview/circle-ci-plugin-architecture.png) + +# Databases + +As we have seen, both the lighthouse-audit-service and catalog-backend require a +database to work with. + +At the time of writing, the lighthouse-audit-service requires PostgreSQL to work +with. The service catalog backend uses an in-memory Sqlite3 instance. This is a +development oriented setup and there are plans to support other databases in the +future. + +To learn more about the future of databases and Backstage, see the following two +GitHub issues. + +[Knex + Plugins (Multiple vs Single Database) · Issue #1598 · spotify/backstage](https://github.com/spotify/backstage/issues/1598) + +[Update migrations to support postgres by dariddler · Pull Request #1527 · spotify/backstage](https://github.com/spotify/backstage/pull/1527#discussion_r450374145) + +# Containerization + +The example Backstage architecture shown above would Dockerize into three +separate docker images. + +1. The frontend container +2. The backend container +3. The lighthouse audit service container + +![Boxes around the architecture to indicate how it is containerised](./architecture-overview/containerised.png) + +The frontend container can be built with a provided command. + +```bash +yarn install +yarn tsc +yarn build +yarn run docker-build +``` + +Running this will simply generate a Docker container containing the contents of +the UIs `dist` directory. The resulting container will be about 50MB in size. + +The backend container can be built by running the following command in the +`packages/backend` directory. + +```bash +yarn run build-image +``` + +This will create a ~500MB container called `example-backend`. + +The lighthouse-audit-service container is already publicly available in Docker +Hub and can be downloaded and ran with + +```bash +docker run spotify/lighthouse-audit-service:latest +``` diff --git a/docs/overview/architecture-overview/backstage-typical-architecture.png b/docs/overview/architecture-overview/backstage-typical-architecture.png new file mode 100644 index 0000000000..44b9967d28 Binary files /dev/null and b/docs/overview/architecture-overview/backstage-typical-architecture.png differ diff --git a/docs/overview/architecture-overview/circle-ci-plugin-architecture.png b/docs/overview/architecture-overview/circle-ci-plugin-architecture.png new file mode 100644 index 0000000000..bc1f2d97cc Binary files /dev/null and b/docs/overview/architecture-overview/circle-ci-plugin-architecture.png differ diff --git a/docs/overview/architecture-overview/circle-ci.png b/docs/overview/architecture-overview/circle-ci.png new file mode 100644 index 0000000000..c5695196f1 Binary files /dev/null and b/docs/overview/architecture-overview/circle-ci.png differ diff --git a/docs/overview/architecture-overview/containerised.png b/docs/overview/architecture-overview/containerised.png new file mode 100644 index 0000000000..55da3c2c36 Binary files /dev/null and b/docs/overview/architecture-overview/containerised.png differ diff --git a/docs/overview/architecture-overview/core-vs-plugin-components-highlighted.png b/docs/overview/architecture-overview/core-vs-plugin-components-highlighted.png new file mode 100644 index 0000000000..7d08fc5e36 Binary files /dev/null and b/docs/overview/architecture-overview/core-vs-plugin-components-highlighted.png differ diff --git a/docs/overview/architecture-overview/lighthouse-plugin-architecture.png b/docs/overview/architecture-overview/lighthouse-plugin-architecture.png new file mode 100644 index 0000000000..0da5d6f042 Binary files /dev/null and b/docs/overview/architecture-overview/lighthouse-plugin-architecture.png differ diff --git a/docs/overview/architecture-overview/lighthouse-plugin.png b/docs/overview/architecture-overview/lighthouse-plugin.png new file mode 100644 index 0000000000..9692fc0833 Binary files /dev/null and b/docs/overview/architecture-overview/lighthouse-plugin.png differ diff --git a/docs/overview/architecture-overview/tech-radar-plugin-architecture.png b/docs/overview/architecture-overview/tech-radar-plugin-architecture.png new file mode 100644 index 0000000000..d55be5ee82 Binary files /dev/null and b/docs/overview/architecture-overview/tech-radar-plugin-architecture.png differ diff --git a/docs/overview/architecture-overview/tech-radar-plugin.png b/docs/overview/architecture-overview/tech-radar-plugin.png new file mode 100644 index 0000000000..dbf39b6c63 Binary files /dev/null and b/docs/overview/architecture-overview/tech-radar-plugin.png differ diff --git a/docs/architecture-terminology.md b/docs/overview/architecture-terminology.md similarity index 97% rename from docs/architecture-terminology.md rename to docs/overview/architecture-terminology.md index 836b2cfa99..6a9d773142 100644 --- a/docs/architecture-terminology.md +++ b/docs/overview/architecture-terminology.md @@ -14,5 +14,3 @@ different ways. Spotify we have over 100 plugins built by over 50 different teams. It has been very powerful to get contributions from various infrastructure teams added into a single unified developer experience. - -[Back to Docs](README.md) diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md new file mode 100644 index 0000000000..36ff658596 --- /dev/null +++ b/docs/overview/roadmap.md @@ -0,0 +1,36 @@ +# Project roadmap + +We created Backstage about 4 years ago. While our internal version of Backstage +has had the benefit of time to mature and evolve, the first iteration of our +open source version is still nascent. We are envisioning three phases of the +project and we have already begun work on various aspects of these phases: + +- 🐣 **Phase 1:** Extensible frontend platform (Done ✅) - You will be able to + easily create a single consistent UI layer for your internal infrastructure + and tools. A set of reusable + [UX patterns and components](http://storybook.backstage.io) help ensure a + consistent experience between tools. + +- 🐢 **Phase 2:** Service Catalog + ([alpha released](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)) - + With a single catalog, Backstage makes it easy for a team to manage ten + services — and makes it possible for your company to manage thousands of them. + Developers can get a uniform overview of all their software and related + resources, regardless of how and where they are running, as well as an easy + way to onboard and manage those resources. + +- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is + different. By fostering a vibrant community of contributors we hope to provide + an ecosystem of Open Source plugins/integrations that allows you to pick the + tools that match your stack. + +Check out our [Milestones](https://github.com/spotify/backstage/milestones) and +open [RFCs](https://github.com/spotify/backstage/labels/rfc) how they relate to +the three Phases outlined above. + +Our vision for Backstage is for it to become the trusted standard toolbox (read: +UX layer) for the open source infrastructure landscape. Think of it like +Kubernetes for developer experience. We realize this is an ambitious goal. We +can’t do it alone. If this sounds interesting or you'd like to help us shape our +product vision, we'd love to talk. You can email me directly: +[alund@spotify.com](mailto:alund@spotify.com). diff --git a/docs/overview/support.md b/docs/overview/support.md new file mode 100644 index 0000000000..7c7390fb69 --- /dev/null +++ b/docs/overview/support.md @@ -0,0 +1,18 @@ +# Support and community + +- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the + project +- [Good First Issues](https://github.com/spotify/backstage/contribute) - Start + here if you want to contribute +- [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the + technical direction +- [FAQ](../FAQ.md) - Frequently Asked Questions +- [Code of Conduct](../../CODE_OF_CONDUCT.md) - This is how we roll +- [Blog](https://backstage.io/blog/) - Announcements and updates +- [Newsletter](https://mailchi.mp/spotify/backstage-community) +- Give us a star ⭐️ - If you are using Backstage or think it is an interesting + project, we would love a star ❤️ + +Or, if you are an open source developer and are interested in joining our team, +please reach out to +[foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com) diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md new file mode 100644 index 0000000000..eb8ce1f906 --- /dev/null +++ b/docs/overview/what-is-backstage.md @@ -0,0 +1,44 @@ +# [Backstage](https://backstage.io) + +![headline](../headline.png) + +## What is Backstage? + +[Backstage](https://backstage.io/) is an open platform for building developer +portals. It’s based on the developer portal we’ve been using internally at +Spotify for over four years. Backstage can be as simple as a services catalog or +as powerful as the UX layer for your entire tech infrastructure. + +For more information go to [backstage.io](https://backstage.io) or join our +[Discord chatroom](https://discord.gg/EBHEGzX). + +### Features + +- Create and manage all of your organization’s software and microservices in one + place. +- Services catalog keeps track of all software and its ownership. +- Visualizations provide information about your backend services and tooling, + and help you monitor them. +- A unified method for managing microservices offers both visibility and + control. +- Preset templates allow engineers to quickly create microservices in a + standardized way + ([coming soon](https://github.com/spotify/backstage/milestone/11)). +- Centralized, full-featured technical documentation with integrated tooling + that makes it easy for developers to set up, publish, and maintain alongside + their code ([coming soon](https://github.com/spotify/backstage/milestone/15)). + +### Benefits + +- For _engineering managers_, it allows you to maintain standards and best + practices across the organization, and can help you manage your whole tech + ecosystem, from migrations to test certification. +- For _end users_ (developers), it makes it fast and simple to build software + components in a standardized way, and it provides a central place to manage + all projects and documentation. +- For _platform engineers_, it enables extensibility and scalability by letting + you easily integrate new tools and services (via plugins), as well as + extending the functionality of existing ones. +- For _everyone_, it’s a single, consistent experience that ties all your + infrastructure tooling, resources, standards, owners, contributors, and + administrators together in one place. diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/plugins/call-existing-api.md b/docs/plugins/call-existing-api.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/getting-started/create-a-plugin.md b/docs/plugins/create-a-plugin.md similarity index 92% rename from docs/getting-started/create-a-plugin.md rename to docs/plugins/create-a-plugin.md index 93c8a4c760..fa91e38b06 100644 --- a/docs/getting-started/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -24,7 +24,7 @@ for your new plugin directly by navigating to `http://localhost:3000/my-plugin`._

- my plugin + my plugin

You can also serve the plugin in isolation by running `yarn start` in the plugin @@ -40,4 +40,4 @@ for it can be found inside the plugin's `dev/` directory. [Next Step - Structure of a plugin](structure-of-a-plugin.md) -[Back to Getting Started](README.md) +[Back to Getting Started](../README.md) diff --git a/docs/plugins/existing-plugins.md b/docs/plugins/existing-plugins.md new file mode 100644 index 0000000000..bd6fd19691 --- /dev/null +++ b/docs/plugins/existing-plugins.md @@ -0,0 +1,11 @@ +# Existing plugins + +## Open source plugins + +The full list of open source plugins can be found +[here](https://github.com/spotify/backstage/tree/master/plugins). + +## Plugin gallery + +TODO: In the future we would like to have something similar to +https://grafana.com/grafana/plugins diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 0000000000..5021ac9896 --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,25 @@ +# Plugins + +Backstage is a single-page application composed of a set of plugins. + +Our goal for the plugin ecosystem is that the definition of a plugin is flexible +enough to allow you to expose pretty much any kind of infrastructure or software +development tool as a plugin in Backstage. By following strong +[design guidelines](../dls/design.md) we ensure the the overall user experience +stays consistent between plugins. + +![plugin](my-plugin_screenshot.png) + +## Creating a plugin + +To create a plugin, follow the steps outlined [here](create-a-plugin.md). + +## Suggesting a plugin + +If you start developing a plugin that you aim to release as open source, we +suggest that you create a new +[new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). +This helps the community know what plugins are in development. + +You can also use this process if you have an idea for a good plugin but you hope +that someone else will pick up the work. diff --git a/docs/getting-started/my-plugin_screenshot.png b/docs/plugins/my-plugin_screenshot.png similarity index 100% rename from docs/getting-started/my-plugin_screenshot.png rename to docs/plugins/my-plugin_screenshot.png diff --git a/docs/getting-started/Plugin development.md b/docs/plugins/plugin-development.md similarity index 100% rename from docs/getting-started/Plugin development.md rename to docs/plugins/plugin-development.md diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/plugins/publish-private.md b/docs/plugins/publish-private.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/publishing.md b/docs/plugins/publishing.md similarity index 74% rename from docs/publishing.md rename to docs/plugins/publishing.md index c66b359add..76997c30d2 100644 --- a/docs/publishing.md +++ b/docs/plugins/publishing.md @@ -3,9 +3,10 @@ ## NPM NPM packages are published through CI/CD in the -[.github/workflows/master.yml](../.github/workflows/master.yml) workflow. Every -commit that is merged to master will be checked for new versions of all public -packages, and any new versions will automatically be published to NPM. +[.github/workflows/master.yml](https://github.com/spotify/backstage/blob/master/.github/workflows/master.yml) +workflow. Every commit that is merged to master will be checked for new versions +of all public packages, and any new versions will automatically be published to +NPM. ### Creating a new release @@ -35,4 +36,4 @@ $ git push origin -u new-release And then create a PR. Once the PR is approved and merged into master, the master build will publish new versions of all bumped packages. -[Back to Docs](README.md) +[Back to Docs](../README.md) diff --git a/docs/getting-started/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md similarity index 96% rename from docs/getting-started/structure-of-a-plugin.md rename to docs/plugins/structure-of-a-plugin.md index a277628b6c..ceacf578fe 100644 --- a/docs/getting-started/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -102,6 +102,6 @@ environment you will probably face challenges like CORS policies and/or backend-side authorization. To smooth this process out you can use proxy - either the one you already have (like nginx/haproxy/etc) or the proxy-backend plugin that we provide for the backstage backend. -[Read more](../../plugins/proxy-backend/README.md) +[Read more](https://github.com/spotify/backstage/blob/master/plugins/proxy-backend/README.md) -[Back to Getting Started](README.md) +[Back to Getting Started](../README.md) diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md new file mode 100644 index 0000000000..701db3eff4 --- /dev/null +++ b/docs/plugins/testing.md @@ -0,0 +1,371 @@ +# Testing with Jest + +Backstage uses [Jest](https://facebook.github.io/jest/) for all our unit testing +needs. + +Jest is a Facebook-built unit testing framework specifically built for React. It +follows in the footsteps of other classic Node.js unit testing-related +frameworks and libraries like [Mocha](https://mochajs.org/), +[Jasmine](https://jasmine.github.io/), and [Chai](http://www.chaijs.com/). + +## Running Tests + +Running all tests: + + yarn test-react + +Running an individual test (e.g. `MyComponent.test.js`): + + yarn test-react MyComponent + +To run both `MyComponent.test.js` and `MyControl.test.js` suite of tests: + + yarn test-react MyCo + +Note: if `console.logs` are not appearing, run only the individual test you are +working on. +[This is a bug in Jest](https://github.com/facebook/jest/issues/2441). + +## Naming Test Files + +Tests should be name `[filename].test.js`. + +For example, the tests for **`Link.js`** exist in the file **`Link.test.js`**. + +## Third-Party Dependencies + +Jest has its own built-in assertion library with `expect`, so there is no need +to `import` a third-party library like some of the older frameworks required. +However since assertion libraries simply throw errors, it would be feasible to +import a third-party library if you needed (like Chai or +[Sinon](http://sinonjs.org/)). + +We use the light-weight +[react-testing-library](https://github.com/kentcdodds/react-testing-library) to +render React components. + +## Testing Utilities + +TODO. + +# Writing Unit Tests + +The following principles are good guides to determining if you are writing high +quality frontend unit tests. + +## Bad Unit Test Principle + +> No unit test is better than a bad one. + +Writing a poor unit test: + +- Gives the illusion your code is more secure or reliable than it actually is. +- Functions equivalent to a bad comment, in that it leads the next developer + into erroneous assumptions. +- Adds to future work by requiring updates to the unit test for irrelevant code + changes. + +## Input/Output Principle + +> A unit test verifies an output matches an expected input. + +For backend, this would be that when you provide configuration X, then the +object responds with Y. For frontend, this would be that when you provide +properties X to a component, then the visual functionality responds with Y. + +## Blackbox Principle + +> A good unit test does not tell the object how it should do its job but should +> only compare inputs to outputs. + +Consider a unit test for a form. A good unit test would not test the order of +the form fields. Instead, it would verify that the inputs to the form fields +lead to a certain backend call when submit is clicked. + +## Scalability Principle + +> Unit test quality is directly proportionate to how much code can change +> without having to touch the unit test. + +This is often overlooked! A unit test is not a test to verify the code never +changes. Poor unit tests are written so that every time you make a tiny change +to the code, you have to update the unit test. A good unit test suite allows a +lot of flexibility in _how_ the code is written so that future refactoring can +occur without having to touch the original unit tests. + +## Increasing Complexity Principle + +> The ordering of unit tests in a suite should proceed from least specific to +> most specific. + +Jest runs all tests in the order in which they are provided, regardless of the +depth of `describe()` blocks you provide. We can use this to help us write tests +that will help the next developer debug what they broke. + +The idea here is that if they were to break a unit test, the next developer +should be able to tell from the order in which the tests broke what they should +do to fix things. + +For example, good unit tests will verify the arguments to a function in a test +prior to a test that validates the output. If you do not test this, then simply +throwing an error saying that output was incorrect will lead the next developer +into thinking they may have broken the entire functionality of the object rather +than simply letting them know they had an invalid input. + +## Broken Functionality Principle + +> Generally, a unit test should not test exactly how the output appears, it +> should test that the functionality has an expected _general_ response to an +> input change. + +This piggybacks the Scalability Principle and applies primarily to frontend +development. As a general rule of thumb, frontends should be flexible enough so +that the UX or design can change while touching the least amount of code +possible. So for example, a poor unit test would verify the color of a button +when it is hovered. This would be a poor unit test, because if you decide to +test a slightly different color on the button the unit test will break. A better +unit test would verify that the button's CSS classname is assigned properly on +hover or test for something completely different. + +## Example: Loading Indicator + +A classic unit test on frontends is verifying a loading indicator displays when +a backend request is being made. + +**Here are some things we could test for _when data is loading_:** + +> Did the internal `loading` state of the component change? (poor) + +This is not a great test because it does not actually test that the +functionality (displaying a message to the user) actually happens. It also +breaks the Blackbox Principle by expecting the internals of the component must +work a certain way. This could be a good test on its own right, but it does not +actually achieve the goal of verifying that if our input (loading data) occurs, +then the output (displaying a message to the user) has happened. + +> Did the text `"Loading!"` appear in the DOM? (better) + +This is a better test because it validates functionality, but it breaks the +Scalability principle. By testing for `'Loading...'` we are linking our test +code to the component's message. If we want to add internationalization or +simply change the message to something more specific we will break our test and +have to update code in two places. + +> Did `` get mounted? (best) + +This is the best test of these examples (there could be more depending on your +implementation). + +Verifying that `` is mounted when data is loading is the best test +because it fulfills all the principles above: + +✓ **Fulfills Input/Output Principle**: Verifies the output changes when the +input changes + +✓ **Fufills Blackbox Principle**: Does not verify _how_ the `` +component is mounted, just that it is mounted in response to the input. + +✓ **Fulfills Scalability Principle**: If we decide to refactor the entire way +the loading indicator is displayed the test still works without touching it. + +✓ **Fulfills Broken Functionality Principle**: this test verifies the +functionality (displaying an indicator) is working, rather than how it is +working. + +The increasing complexity principle does not really apply to this example, so it +was excluded. However if you were to place this test in a suite of other tests, +it would be best to test first that when the component is instructed to load +data then it actually does it. this way both tests fail if the data loading part +breaks and the next developer immediately know the problem is that the data +loading is broken, not that the loading indicator is broken. + +# Examples + +## Utility Functions + +A utility function is a function with no side effects. It takes in arguments and +returns a result or displays an error or console message, like so: + +**`StringUtil ellipsis`** + + export function ellipsis(text, maxLength, midCharIx = 0, ellipsis = '...') { + // Do something blackbox. We should not care about the internals, only inputs and outputs. + ... + return someFinalValue; + } + +There are four things to test for in a utility function: + +1. Handle Invalid Input +2. Verify default input arguments +3. Verify output for expected input arguments +4. Handle thrown errors + +> Handle Invalid Input (handle thrown errors): + + it('Throws an error on improper arguments', () => { + expect(() => { + ellipsis(); + }).toThrowError('Expected \'text\' to be defined'); + }); + +> Verify default input arguments: + + it('Works with defaults', () => { + expect(ellipsis('Hello world', 3)).toBe('Hel...'); + expect(ellipsis('', 3)).toBe(''); + expect(ellipsis('H', 3)).toBe('H'); + expect(ellipsis('Hello', 5)).toBe('Hello'); + }); + +> Verify output for expected input arguments: + +This is especially true for edge cases! + + it('Works with midCharIx', () => { + expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); + expect(ellipsis('', 3, 6)).toBe(''); + expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); + }); + +## Non-React Classes + +Testing a Javascript object which is _not_ a React component follows a lot of +the same principles as testing objects in other languages. + +### API Testing Principles + +Testing an API involves verifying four things: + +1. Invalid inputs are caught before being sent to the server. +2. Valid inputs translate into a valid browser request. +3. Server response is translated into an expected Javascript object. +4. Server errors are handled gracefully. + +### Mocking API Calls + +[Mocking in jest](https://facebook.github.io/jest/docs/en/mock-functions.html) +involves wrapping existing functions (like an API call function) with an +alternative. + +For example: + +**./Api.js** + +``` +export { + fetchSomethingFromServer: () => { + // Live production call to a URI. Must be avoided during testing! + return fetch('blah'); + } +}; +``` + +**./\_\_mocks\_\_/Api.js** + +``` +export { + fetchSomethingFromServer: () => { + // Simulate a production call, but avoid jest and just use a promise + return Promise.resolve('some result object simulating server data here'); + } +} +``` + +**./Api.test.js** + +``` +/* eslint-disable import/first */ + +jest.mock('./MyApi'); // Instruct Jest to swap all future imports of './MyApi.js' to './__mocks__/MyApi.js' + +import MyApi from './MyApi'; // Will actually return the contents of the file in the __mocks__ folder now + +it ('loads data', (done) => { + MyApi.fetchSomethingFromServer().then(result => { + expect(result).toBe('some result object simulating server data here'); + done(); + }); +}); +``` + +Note: make sure you disable the eslint `'import/first'` rule at the top of the +file since technically you are not allowed by the default settings to have an +import after the `jest.mock` call. + +## React Components + +### Working with the React Lifecycle + +The [React lifecycle](https://reactjs.org/docs/state-and-lifecycle.html) is +asynchronous. + +When you call `setState` or update the `props` of a component, there are several +asynchronous stages that must occur before a rerender. Note the following +example: + +```jsx +class MyComponent extends Component { + load() { + this.setState({loading: true}); + } + + render() { + return this.state.loading ? : 'Finished!'; + } +} + +... + +// INCORRECT +it('Test loading', () => { + const wrapper = mount(); + wrapper.load(); + expect(wrapper.find('Loading').length).toEqual(1); // Will fail +}); + +// CORRECT +it('Test loading', () => { + const wrapper = mount(); + wrapper.load(); + wrapper.update(); // This tells the components to run through a render cycle + expect(wrapper.find('Loading').length).toEqual(1); +}); +``` + +For more information: + +- [React lifecycle](https://reactjs.org/docs/state-and-lifecycle.html) + +### Accessing `store`, `theme`, routing, browser history, etc. + +The Backstage application has several core providers at its root. To run your +test wrapped in a "dummy" Backstage application, you can use our utility +functions: + +**`wrapInTestApp`** + + import { wrapInTestApp } from '../../test-utils'; + ... + it('Definitely is not a coconut', () => { + const mangoWrapper = mount(wrapInTestApp()); + + expect(mangoWrapper.context().store).toBeDefined(); + }); + +Note: wrapping in the test application **requires** you to do a `find()` or +`dive()` since the wrapped component is now the application. + +# Debugging Jest Tests + +Currently, debugging Jest tests using IntelliJ or `node-debugger` is possible +but can be +[problematic to set up.](https://intellij-support.jetbrains.com/hc/en-us/community/posts/115000634564-Debugging-Jest-unit-tests) + +It is possible, but you might spend a decent amount of time configuring your +IDE. + +In most cases, we have found that using `console.log` works well. + +Note: if your console.logs are not being displayed, focus your specific unit +test from the command line by running them like so `yarn test-react MyTest`. diff --git a/docs/reference/README.md b/docs/reference/README.md deleted file mode 100644 index dd926d57dc..0000000000 --- a/docs/reference/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Reference documentation - -APIs and Components - -- [createPlugin](createPlugin.md) -- [createPlugin - router](createPlugin-router.md) -- [createPlugin - feature flags](createPlugin-feature-flags.md) - -[Back to Docs](../README.md) diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index 9cb1202b60..0f1debd515 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -64,5 +64,3 @@ const ExampleButton: FC<{}> = () => { ); }; ``` - -[Back to References](README.md) diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md index d296d1179d..831b27f388 100644 --- a/docs/reference/createPlugin-router.md +++ b/docs/reference/createPlugin-router.md @@ -35,4 +35,4 @@ const myPluginRouteRef = createRouteRef({ }); ``` -[Back to References](README.md) +[Back to References](../README.md) diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index de4636e11d..7ecb6d461a 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -39,5 +39,3 @@ export default createPlugin({ }, }); ``` - -[Back to References](README.md) diff --git a/docs/reference/utility-apis/AlertApi.md b/docs/reference/utility-apis/AlertApi.md new file mode 100644 index 0000000000..1f0db84e29 --- /dev/null +++ b/docs/reference/utility-apis/AlertApi.md @@ -0,0 +1,114 @@ +# AlertApi + +The AlertApi type is defined at +[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L29). + +The following Utility API implements this type: [alertApiRef](./README.md#alert) + +## Members + +### post() + +Post an alert for handling by the application. + +
+post(alert: AlertMessage): void
+
+ +### alert\$() + +Observe alerts posted by other parts of the application. + +
+alert$(): Observable<AlertMessage>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AlertMessage + +
+export type AlertMessage = {
+  message: string;
+  // Severity will default to success since that is what material ui defaults the value to.
+  severity?: 'success' | 'info' | 'warning' | 'error';
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L19). + +Referenced by: [post](#post), [alert\$](#alert). + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [alert\$](#alert). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md new file mode 100644 index 0000000000..02ab83afff --- /dev/null +++ b/docs/reference/utility-apis/AppThemeApi.md @@ -0,0 +1,225 @@ +# AppThemeApi + +The AppThemeApi type is defined at +[packages/core-api/src/apis/definitions/AppThemeApi.ts:50](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50). + +The following Utility API implements this type: +[appThemeApiRef](./README.md#apptheme) + +## Members + +### getInstalledThemes() + +Get a list of available themes. + +
+getInstalledThemes(): AppTheme[]
+
+ +### activeThemeId\$() + +Observe the currently selected theme. A value of undefined means no specific +theme has been selected. + +
+activeThemeId$(): Observable<string | undefined>
+
+ +### getActiveThemeId() + +Get the current theme ID. Returns undefined if no specific theme is selected. + +
+getActiveThemeId(): string | undefined
+
+ +### setActiveThemeId() + +Set a specific theme to use in the app, overriding the default theme selection. + +Clear the selection by passing in undefined. + +
+setActiveThemeId(themeId?: string): void
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AppTheme + +Describes a theme provided by the app. + +
+export type AppTheme = {
+  /**
+   * ID used to remember theme selections.
+   */
+  id: string;
+
+  /**
+   * Title of the theme
+   */
+  title: string;
+
+  /**
+   * Theme variant
+   */
+  variant: 'light' | 'dark';
+
+  /**
+   * The specialized MaterialUI theme instance.
+   */
+  theme: BackstageTheme;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24). + +Referenced by: [getInstalledThemes](#getinstalledthemes). + +### BackstagePalette + +
+export type BackstagePalette = Palette & PaletteAdditions
+
+ +Defined at +[packages/theme/src/types.ts:63](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L63). + +Referenced by: [BackstageTheme](#backstagetheme). + +### BackstageTheme + +
+export interface BackstageTheme extends Theme {
+  palette: BackstagePalette;
+}
+
+ +Defined at +[packages/theme/src/types.ts:66](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L66). + +Referenced by: [AppTheme](#apptheme). + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [activeThemeId\$](#activethemeid). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### PaletteAdditions + +
+type PaletteAdditions = {
+  status: {
+    ok: string;
+    warning: string;
+    error: string;
+    pending: string;
+    running: string;
+    aborted: string;
+  };
+  border: string;
+  textContrast: string;
+  textVerySubtle: string;
+  textSubtle: string;
+  highlight: string;
+  errorBackground: string;
+  warningBackground: string;
+  infoBackground: string;
+  errorText: string;
+  infoText: string;
+  warningText: string;
+  linkHover: string;
+  link: string;
+  gold: string;
+  sidebar: string;
+  tabbar: {
+    indicator: string;
+  };
+  bursts: {
+    fontColor: string;
+    slackChannelText: string;
+    backgroundColor: {
+      default: string;
+    };
+  };
+  pinSidebarButton: {
+    icon: string;
+    background: string;
+  };
+}
+
+ +Defined at +[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L23). + +Referenced by: [BackstagePalette](#backstagepalette). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md new file mode 100644 index 0000000000..a9e0203403 --- /dev/null +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -0,0 +1,88 @@ +# BackstageIdentityApi + +The BackstageIdentityApi type is defined at +[packages/core-api/src/apis/definitions/auth.ts:144](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L144). + +The following Utility APIs implement this type: + +- [githubAuthApiRef](./README.md#githubauth) + +- [gitlabAuthApiRef](./README.md#gitlabauth) + +- [googleAuthApiRef](./README.md#googleauth) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### getBackstageIdentity() + +Get the user's identity within Backstage. This should normally not be called +directly, use the @IdentityApi instead. + +If the optional flag is not set, a session is guaranteed to be returned, while +if the optional flag is set, the session may be undefined. See +@AuthRequestOptions for more details. + +
+getBackstageIdentity(
+    options?: AuthRequestOptions,
+  ): Promise<BackstageIdentity | undefined>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AuthRequestOptions + +
+export type AuthRequestOptions = {
+  /**
+   * If this is set to true, the user will not be prompted to log in,
+   * and an empty response will be returned if there is no existing session.
+   *
+   * This can be used to perform a check whether the user is logged in, or if you don't
+   * want to force a user to be logged in, but provide functionality if they already are.
+   *
+   * @default false
+   */
+  optional?: boolean;
+
+  /**
+   * If this is set to true, the request will bypass the regular oauth login modal
+   * and open the login popup directly.
+   *
+   * The method must be called synchronously from a user action for this to work in all browsers.
+   *
+   * @default false
+   */
+  instantPopup?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40). + +Referenced by: [getBackstageIdentity](#getbackstageidentity). + +### BackstageIdentity + +
+export type BackstageIdentity = {
+  /**
+   * The backstage user ID.
+   */
+  id: string;
+
+  /**
+   * An ID token that can be used to authenticate the user within Backstage.
+   */
+  idToken: string;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:157](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L157). + +Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md new file mode 100644 index 0000000000..bca590bc9b --- /dev/null +++ b/docs/reference/utility-apis/Config.md @@ -0,0 +1,179 @@ +# Config + +The Config type is defined at +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L32). + +The following Utility API implements this type: +[configApiRef](./README.md#config) + +## Members + +### keys() + +
+keys(): string[]
+
+ +### get() + +
+get(key: string): JsonValue
+
+ +### getOptional() + +
+getOptional(key: string): JsonValue | undefined
+
+ +### getConfig() + +
+getConfig(key: string): Config
+
+ +### getOptionalConfig() + +
+getOptionalConfig(key: string): Config | undefined
+
+ +### getConfigArray() + +
+getConfigArray(key: string): Config[]
+
+ +### getOptionalConfigArray() + +
+getOptionalConfigArray(key: string): Config[] | undefined
+
+ +### getNumber() + +
+getNumber(key: string): number
+
+ +### getOptionalNumber() + +
+getOptionalNumber(key: string): number | undefined
+
+ +### getBoolean() + +
+getBoolean(key: string): boolean
+
+ +### getOptionalBoolean() + +
+getOptionalBoolean(key: string): boolean | undefined
+
+ +### getString() + +
+getString(key: string): string
+
+ +### getOptionalString() + +
+getOptionalString(key: string): string | undefined
+
+ +### getStringArray() + +
+getStringArray(key: string): string[]
+
+ +### getOptionalStringArray() + +
+getOptionalStringArray(key: string): string[] | undefined
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### Config + +
+export type Config = {
+  keys(): string[];
+
+  get(key: string): JsonValue;
+  getOptional(key: string): JsonValue | undefined;
+
+  getConfig(key: string): Config;
+  getOptionalConfig(key: string): Config | undefined;
+
+  getConfigArray(key: string): Config[];
+  getOptionalConfigArray(key: string): Config[] | undefined;
+
+  getNumber(key: string): number;
+  getOptionalNumber(key: string): number | undefined;
+
+  getBoolean(key: string): boolean;
+  getOptionalBoolean(key: string): boolean | undefined;
+
+  getString(key: string): string;
+  getOptionalString(key: string): string | undefined;
+
+  getStringArray(key: string): string[];
+  getOptionalStringArray(key: string): string[] | undefined;
+}
+
+ +Defined at +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L32). + +Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig), +[getConfigArray](#getconfigarray), +[getOptionalConfigArray](#getoptionalconfigarray), [Config](#config). + +### JsonArray + +
+export type JsonArray = JsonValue[]
+
+ +Defined at +[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L18). + +Referenced by: [JsonValue](#jsonvalue). + +### JsonObject + +
+export type JsonObject = { [key in string]?: JsonValue }
+
+ +Defined at +[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L17). + +Referenced by: [JsonValue](#jsonvalue). + +### JsonValue + +
+export type JsonValue =
+  | JsonObject
+  | JsonArray
+  | number
+  | string
+  | boolean
+  | null
+
+ +Defined at +[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L19). + +Referenced by: [get](#get), [getOptional](#getoptional), +[JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config). diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md new file mode 100644 index 0000000000..10ae47e6df --- /dev/null +++ b/docs/reference/utility-apis/ErrorApi.md @@ -0,0 +1,134 @@ +# ErrorApi + +The ErrorApi type is defined at +[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). + +The following Utility API implements this type: [errorApiRef](./README.md#error) + +## Members + +### post() + +Post an error for handling by the application. + +
+post(error: Error, context?: ErrorContext): void
+
+ +### error\$() + +Observe errors posted by other parts of the application. + +
+error$(): Observable<{ error: Error; context?: ErrorContext }>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### Error + +Mirrors the javascript Error class, for the purpose of providing documentation +and optional fields. + +
+type Error = {
+  name: string;
+  message: string;
+  stack?: string;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). + +Referenced by: [post](#post), [error\$](#error). + +### ErrorContext + +Provides additional information about an error that was posted to the +application. + +
+export type ErrorContext = {
+  // If set to true, this error should not be displayed to the user. Defaults to false.
+  hidden?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). + +Referenced by: [post](#post), [error\$](#error). + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [error\$](#error). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md new file mode 100644 index 0000000000..5069c9272c --- /dev/null +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -0,0 +1,33 @@ +# FeatureFlagsApi + +The FeatureFlagsApi type is defined at +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41). + +The following Utility API implements this type: +[featureFlagsApiRef](./README.md#featureflags) + +## Members + +### registeredFeatureFlags + +Store a list of registered feature flags. + +
+registeredFeatureFlags: FeatureFlagsRegistryItem[]
+
+ +### getFlags() + +Get a list of all feature flags from the current user. + +
+getFlags(): UserFlags
+
+ +### getRegisteredFlags() + +Get a list of all registered flags. + +
+getRegisteredFlags(): FeatureFlagsRegistry
+
diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md new file mode 100644 index 0000000000..2c94b7fcd0 --- /dev/null +++ b/docs/reference/utility-apis/IdentityApi.md @@ -0,0 +1,81 @@ +# IdentityApi + +The IdentityApi type is defined at +[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). + +The following Utility API implements this type: +[identityApiRef](./README.md#identity) + +## Members + +### getUserId() + +The ID of the signed in user. This ID is not meant to be presented to the user, +but used as an opaque string to pass on to backends or use in frontend logic. + +TODO: The intention of the user ID is to be able to tie the user to an identity +that is known by the catalog and/or identity backend. It should for example be +possible to fetch all owned components using this ID. + +
+getUserId(): string
+
+ +### getProfile() + +The profile of the signed in user. + +
+getProfile(): ProfileInfo
+
+ +### getIdToken() + +An OpenID Connect ID Token which proves the identity of the signed in user. + +The ID token will be undefined if the signed in user does not have a verified +identity, such as a demo user or mocked user for e2e tests. + +
+getIdToken(): Promise<string | undefined>
+
+ +### logout() + +Log out the current user + +
+logout(): Promise<void>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### ProfileInfo + +Profile information of the user. + +
+export type ProfileInfo = {
+  /**
+   * Email ID.
+   */
+  email?: string;
+
+  /**
+   * Display name that can be presented to the user.
+   */
+  displayName?: string;
+
+  /**
+   * URL to an avatar image of the user.
+   */
+  picture?: string;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L172). + +Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md new file mode 100644 index 0000000000..09a4c4b1b4 --- /dev/null +++ b/docs/reference/utility-apis/OAuthApi.md @@ -0,0 +1,119 @@ +# OAuthApi + +The OAuthApi type is defined at +[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L67). + +The following Utility APIs implement this type: + +- [githubAuthApiRef](./README.md#githubauth) + +- [gitlabAuthApiRef](./README.md#gitlabauth) + +- [googleAuthApiRef](./README.md#googleauth) + +- [oauth2ApiRef](./README.md#oauth2) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### getAccessToken() + +Requests an OAuth 2 Access Token, optionally with a set of scopes. The access +token allows you to make requests on behalf of the user, and the copes may grant +you broader access, depending on the auth provider. + +Each auth provider has separate handling of scope, so you need to look at the +documentation for each one to know what scope you need to request. + +This method is cheap and should be called each time an access token is used. Do +not for example store the access token in React component state, as that could +cause the token to expire. Instead fetch a new access token for each request. + +Be sure to include all required scopes when requesting an access token. When +testing your implementation it is best to log out the Backstage session and then +visit your plugin page directly, as you might already have some required scopes +in your existing session. Not requesting the correct scopes can lead to 403 or +other authorization errors, which can be tricky to debug. + +If the user has not yet granted access to the provider and the set of requested +scopes, the user will be prompted to log in. The returned promise will not +resolve until the user has successfully logged in. The returned promise can be +rejected, but only if the user rejects the login request. + +
+getAccessToken(
+    scope?: OAuthScope,
+    options?: AuthRequestOptions,
+  ): Promise<string>
+
+ +### logout() + +Log out the user's session. This will reload the page. + +
+logout(): Promise<void>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AuthRequestOptions + +
+export type AuthRequestOptions = {
+  /**
+   * If this is set to true, the user will not be prompted to log in,
+   * and an empty response will be returned if there is no existing session.
+   *
+   * This can be used to perform a check whether the user is logged in, or if you don't
+   * want to force a user to be logged in, but provide functionality if they already are.
+   *
+   * @default false
+   */
+  optional?: boolean;
+
+  /**
+   * If this is set to true, the request will bypass the regular oauth login modal
+   * and open the login popup directly.
+   *
+   * The method must be called synchronously from a user action for this to work in all browsers.
+   *
+   * @default false
+   */
+  instantPopup?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40). + +Referenced by: [getAccessToken](#getaccesstoken). + +### OAuthScope + +This file contains declarations for common interfaces of auth-related APIs. The +declarations should be used to signal which type of authentication and +authorization methods each separate auth provider supports. + +For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, +would be declared as follows: + +const googleAuthApiRef = createApiRef({ ... }) + +An array of scopes, or a scope string formatted according to the auth provider, +which is typically a space separated list. + +See the documentation for each auth provider for the list of scopes supported by +each provider. + +
+export type OAuthScope = string | string[]
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L38). + +Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md new file mode 100644 index 0000000000..fc38ca827b --- /dev/null +++ b/docs/reference/utility-apis/OAuthRequestApi.md @@ -0,0 +1,232 @@ +# OAuthRequestApi + +The OAuthRequestApi type is defined at +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). + +The following Utility API implements this type: +[oauthRequestApiRef](./README.md#oauthrequest) + +## Members + +### createAuthRequester() + +A utility for showing login popups or similar things, and merging together +multiple requests for different scopes into one request that inclues all scopes. + +The passed in options provide information about the login provider, and how to +handle auth requests. + +The returned AuthRequester function is used to request login with new scopes. +These requests are merged together and forwarded to the auth handler, as soon as +a consumer of auth requests triggers an auth flow. + +See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. + +
+createAuthRequester<AuthResponse>(
+    options: AuthRequesterOptions<AuthResponse>,
+  ): AuthRequester<AuthResponse>
+
+ +### authRequest\$() + +Observers panding auth requests. The returned observable will emit all current +active auth request, at most one for each created auth requester. + +Each request has its own info about the login provider, forwarded from the auth +requester options. + +Depending on user interaction, the request should either be rejected, or used to +trigger the auth handler. If the request is rejected, all pending AuthRequester +calls will fail with a "RejectedError". If a auth is triggered, and the auth +handler resolves successfully, then all currently pending AuthRequester calls +will resolve to the value returned by the onAuthRequest call. + +
+authRequest$(): Observable<PendingAuthRequest[]>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AuthProvider + +Information about the auth provider that we're requesting a login towards. + +This should be shown to the user so that they can be informed about what login +is being requested before a popup is shown. + +
+export type AuthProvider = {
+  /**
+   * Title for the auth provider, for example "GitHub"
+   */
+  title: string;
+
+  /**
+   * Icon for the auth provider.
+   */
+  icon: IconComponent;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). + +Referenced by: [AuthRequesterOptions](#authrequesteroptions), +[PendingAuthRequest](#pendingauthrequest). + +### AuthRequester + +Function used to trigger new auth requests for a set of scopes. + +The returned promise will resolve to the same value returned by the +onAuthRequest in the AuthRequesterOptions. Or rejected, if the request is +rejected. + +This function can be called multiple times before the promise resolves. All +calls will be merged into one request, and the scopes forwarded to the +onAuthRequest will be the union of all requested scopes. + +
+export type AuthRequester<AuthResponse> = (
+  scopes: Set<string>,
+) => Promise<AuthResponse>
+
+ +Defined at +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). + +Referenced by: [createAuthRequester](#createauthrequester). + +### AuthRequesterOptions + +Describes how to handle auth requests. Both how to show them to the user, and +what to do when the user accesses the auth request. + +
+export type AuthRequesterOptions<AuthResponse> = {
+  /**
+   * Information about the auth provider, which will be forwarded to auth requests.
+   */
+  provider: AuthProvider;
+
+  /**
+   * Implementation of the auth flow, which will be called synchronously when
+   * trigger() is called on an auth requests.
+   */
+  onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). + +Referenced by: [createAuthRequester](#createauthrequester). + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [authRequest\$](#authrequest). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### PendingAuthRequest + +An pending auth request for a single auth provider. The request will remain in +this pending state until either reject() or trigger() is called. + +Any new requests for the same provider are merged into the existing pending +request, meaning there will only ever be a single pending request for a given +provider. + +
+export type PendingAuthRequest = {
+  /**
+   * Information about the auth provider, as given in the AuthRequesterOptions
+   */
+  provider: AuthProvider;
+
+  /**
+   * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
+   */
+  reject: () => void;
+
+  /**
+   * Trigger the auth request to continue the auth flow, by for example showing a popup.
+   *
+   * Synchronously calls onAuthRequest with all scope currently in the request.
+   */
+  trigger(): Promise<void>;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). + +Referenced by: [authRequest\$](#authrequest). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md new file mode 100644 index 0000000000..ecbf439b84 --- /dev/null +++ b/docs/reference/utility-apis/OpenIdConnectApi.md @@ -0,0 +1,75 @@ +# OpenIdConnectApi + +The OpenIdConnectApi type is defined at +[packages/core-api/src/apis/definitions/auth.ts:104](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L104). + +The following Utility APIs implement this type: + +- [googleAuthApiRef](./README.md#googleauth) + +- [oauth2ApiRef](./README.md#oauth2) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### getIdToken() + +Requests an OpenID Connect ID Token. + +This method is cheap and should be called each time an ID token is used. Do not +for example store the id token in React component state, as that could cause the +token to expire. Instead fetch a new id token for each request. + +If the user has not yet logged in to Google inside Backstage, the user will be +prompted to log in. The returned promise will not resolve until the user has +successfully logged in. The returned promise can be rejected, but only if the +user rejects the login request. + +
+getIdToken(options?: AuthRequestOptions): Promise<string>
+
+ +### logout() + +Log out the user's session. This will reload the page. + +
+logout(): Promise<void>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AuthRequestOptions + +
+export type AuthRequestOptions = {
+  /**
+   * If this is set to true, the user will not be prompted to log in,
+   * and an empty response will be returned if there is no existing session.
+   *
+   * This can be used to perform a check whether the user is logged in, or if you don't
+   * want to force a user to be logged in, but provide functionality if they already are.
+   *
+   * @default false
+   */
+  optional?: boolean;
+
+  /**
+   * If this is set to true, the request will bypass the regular oauth login modal
+   * and open the login popup directly.
+   *
+   * The method must be called synchronously from a user action for this to work in all browsers.
+   *
+   * @default false
+   */
+  instantPopup?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40). + +Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md new file mode 100644 index 0000000000..469c923cb0 --- /dev/null +++ b/docs/reference/utility-apis/ProfileInfoApi.md @@ -0,0 +1,94 @@ +# ProfileInfoApi + +The ProfileInfoApi type is defined at +[packages/core-api/src/apis/definitions/auth.ts:127](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L127). + +The following Utility APIs implement this type: + +- [githubAuthApiRef](./README.md#githubauth) + +- [gitlabAuthApiRef](./README.md#gitlabauth) + +- [googleAuthApiRef](./README.md#googleauth) + +- [oauth2ApiRef](./README.md#oauth2) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### getProfile() + +Get profile information for the user as supplied by this auth provider. + +If the optional flag is not set, a session is guaranteed to be returned, while +if the optional flag is set, the session may be undefined. See +@AuthRequestOptions for more details. + +
+getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### AuthRequestOptions + +
+export type AuthRequestOptions = {
+  /**
+   * If this is set to true, the user will not be prompted to log in,
+   * and an empty response will be returned if there is no existing session.
+   *
+   * This can be used to perform a check whether the user is logged in, or if you don't
+   * want to force a user to be logged in, but provide functionality if they already are.
+   *
+   * @default false
+   */
+  optional?: boolean;
+
+  /**
+   * If this is set to true, the request will bypass the regular oauth login modal
+   * and open the login popup directly.
+   *
+   * The method must be called synchronously from a user action for this to work in all browsers.
+   *
+   * @default false
+   */
+  instantPopup?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40). + +Referenced by: [getProfile](#getprofile). + +### ProfileInfo + +Profile information of the user. + +
+export type ProfileInfo = {
+  /**
+   * Email ID.
+   */
+  email?: string;
+
+  /**
+   * Display name that can be presented to the user.
+   */
+  displayName?: string;
+
+  /**
+   * URL to an avatar image of the user.
+   */
+  picture?: string;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L172). + +Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md new file mode 100644 index 0000000000..66da8403b0 --- /dev/null +++ b/docs/reference/utility-apis/README.md @@ -0,0 +1,139 @@ +# Backstage Core Utility APIs + +The following is a list of all Utility APIs defined by `@backstage/core`. They +are available to use by plugins and components, and can be accessed using the +`useApi` hook, also provided by `@backstage/core`. For more information, see +https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md. + +### alert + +Used to report alerts and forward them to the app + +Implemented type: [AlertApi](./AlertApi.md) + +ApiRef: +[alertApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L41) + +### appTheme + +API Used to configure the app theme, and enumerate options + +Implemented type: [AppThemeApi](./AppThemeApi.md) + +ApiRef: +[appThemeApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) + +### config + +Used to access runtime configuration + +Implemented type: [Config](./Config.md) + +ApiRef: +[configApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) + +### error + +Used to report errors and forward them to the app + +Implemented type: [ErrorApi](./ErrorApi.md) + +ApiRef: +[errorApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) + +### featureFlags + +Used to toggle functionality in features across Backstage + +Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) + +ApiRef: +[featureFlagsApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) + +### githubAuth + +Provides authentication towards Github APIs + +Implemented types: [OAuthApi](./OAuthApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[githubAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L230) + +### gitlabAuth + +Provides authentication towards Gitlab APIs + +Implemented types: [OAuthApi](./OAuthApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L260) + +### googleAuth + +Provides authentication towards Google APIs and identities + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[googleAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L213) + +### identity + +Provides access to the identity of the signed in user + +Implemented type: [IdentityApi](./IdentityApi.md) + +ApiRef: +[identityApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) + +### oauth2 + +Example of how to use oauth2 custom provider + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), [SessionStateApi](./SessionStateApi.md) + +ApiRef: +[oauth2ApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L270) + +### oauthRequest + +An API for implementing unified OAuth flows in Backstage + +Implemented type: [OAuthRequestApi](./OAuthRequestApi.md) + +ApiRef: +[oauthRequestApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) + +### oktaAuth + +Provides authentication towards Okta APIs + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[oktaAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L243) + +### storage + +Provides the ability to store data which is unique to the user + +Implemented type: [StorageApi](./StorageApi.md) + +ApiRef: +[storageApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L68) diff --git a/docs/reference/utility-apis/SessionStateApi.md b/docs/reference/utility-apis/SessionStateApi.md new file mode 100644 index 0000000000..7ed67cbc62 --- /dev/null +++ b/docs/reference/utility-apis/SessionStateApi.md @@ -0,0 +1,115 @@ +# SessionStateApi + +The SessionStateApi type is defined at +[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L201). + +The following Utility APIs implement this type: + +- [githubAuthApiRef](./README.md#githubauth) + +- [gitlabAuthApiRef](./README.md#gitlabauth) + +- [googleAuthApiRef](./README.md#googleauth) + +- [oauth2ApiRef](./README.md#oauth2) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### sessionState\$() + +
+sessionState$(): Observable<SessionState>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [sessionState\$](#sessionstate). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### SessionState + +Session state values passed to subscribers of the SessionStateApi. + +
+export enum SessionState {
+  SignedIn = 'SignedIn',
+  SignedOut = 'SignedOut',
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L192). + +Referenced by: [sessionState\$](#sessionstate). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md new file mode 100644 index 0000000000..1eadec1302 --- /dev/null +++ b/docs/reference/utility-apis/StorageApi.md @@ -0,0 +1,186 @@ +# StorageApi + +The StorageApi type is defined at +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L31). + +The following Utility API implements this type: +[storageApiRef](./README.md#storage) + +## Members + +### forBucket() + +Create a bucket to store data in. + +
+forBucket(name: string): StorageApi
+
+ +### get() + +Get the current value for persistent data, use observe\$ to be notified of +updates. + +
+get<T>(key: string): T | undefined
+
+ +### remove() + +Remove persistent data. + +
+remove(key: string): Promise<void>
+
+ +### set() + +Save persistant data, and emit messages to anyone that is using observe\$ for +this key + +
+set(key: string, data: any): Promise<void>
+
+ +### observe\$() + +Observe changes on a particular key in the bucket + +
+observe$<T>(key: string): Observable<StorageValueChange<T>>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53). + +Referenced by: [observe\$](#observe), [StorageApi](#storageapi). + +### Observer + +This file contains non-react related core types used throught Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### StorageApi + +
+export interface StorageApi {
+  /**
+   * Create a bucket to store data in.
+   * @param {String} name Namespace for the storage to be stored under,
+   *                      will inherit previous namespaces too
+   */
+  forBucket(name: string): StorageApi;
+
+  /**
+   * Get the current value for persistent data, use observe$ to be notified of updates.
+   *
+   * @param {String} key Unique key associated with the data.
+   * @return {Object} data The data that should is stored.
+   */
+  get<T>(key: string): T | undefined;
+
+  /**
+   * Remove persistent data.
+   *
+   * @param {String} key Unique key associated with the data.
+   */
+  remove(key: string): Promise<void>;
+
+  /**
+   * Save persistant data, and emit messages to anyone that is using observe$ for this key
+   *
+   * @param {String} key Unique key associated with the data.
+   */
+  set(key: string, data: any): Promise<void>;
+
+  /**
+   * Observe changes on a particular key in the bucket
+   * @param {String} key Unique key associated with the data
+   */
+  observe$<T>(key: string): Observable<StorageValueChange<T>>;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L31). + +Referenced by: [forBucket](#forbucket). + +### StorageValueChange + +
+export type StorageValueChange<T = any> = {
+  key: string;
+  newValue?: T;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L21). + +Referenced by: [observe\$](#observe), [StorageApi](#storageapi). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33). + +Referenced by: [Observable](#observable). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/install/kubernetes/app.yaml b/install/kubernetes/app.yaml index ed4108f11b..ce284afd31 100644 --- a/install/kubernetes/app.yaml +++ b/install/kubernetes/app.yaml @@ -11,17 +11,17 @@ spec: matchLabels: app: backstage component: frontend - template: - metadata: - labels: - app: backstage - component: frontend - spec: - containers: - - name: app - image: spotify/backstage:latest - imagePullPolicy: Always - ports: - - containerPort: 80 - name: app - protocol: TCP + template: + metadata: + labels: + app: backstage + component: frontend + spec: + containers: + - name: app + image: spotify/backstage:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: app + protocol: TCP diff --git a/install/kubernetes/backend.yaml b/install/kubernetes/backend.yaml index 669426fecd..bc82731958 100644 --- a/install/kubernetes/backend.yaml +++ b/install/kubernetes/backend.yaml @@ -11,17 +11,17 @@ spec: matchLabels: app: backstage component: backend - template: - metadata: - labels: - app: backstage - component: backend - spec: - containers: - - name: backend - image: spotify/backstage-backend:latest - imagePullPolicy: Always - ports: - - containerPort: 7000 - name: backend - protocol: TCP + template: + metadata: + labels: + app: backstage + component: backend + spec: + containers: + - name: backend + image: spotify/backstage-backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 7000 + name: backend + protocol: TCP diff --git a/lerna.json b/lerna.json index 1b65340fec..1a316638fb 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.13" + "version": "0.1.1-alpha.16" } diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000000..7d5d58b64a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,100 @@ +site_name: 'Backstage' +site_description: 'Main documentation for Backstage features and platform APIs' + +nav: + - Overview: + - What is Backstage?: 'overview/what-is-backstage.md' + - Backstage architecture: 'overview/architecture-overview.md' + - Architecture and terminology: 'overview/architecture-terminology.md' + - Roadmap: 'overview/roadmap.md' + - Getting started: + - Running Backstage locally: 'getting-started/index.md' + - Installation: 'getting-started/installation.md' + - Local development: 'getting-started/development-environment.md' + - Demo deployment: https://backstage-demo.roadie.io + - Production deployments: + - Create an App: 'getting-started/create-an-app.md' + - App configuration: + - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' + - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' + - Deployment scenarios: + - Kubernetes: 'getting-started/deployment-k8s.md' + - Other: 'getting-started/deployment-other.md' + - Features: + - Software Catalog: + - Overview: 'features/software-catalog/index.md' + - System model: 'features/software-catalog/system-model.md' + - YAML File Format: 'features/software-catalog/descriptor-format.md' + - Populating the catalog: 'features/software-catalog/populating.md' + - Extending the model: 'features/software-catalog/extending-the-model.md' + - External integrations: 'features/software-catalog/external-integrations.md' + - API: 'features/software-catalog/api.md' + - Software creation templates: + - Overview: 'features/software-templates/index.md' + - Adding templates: 'features/software-templates/adding-templates.md' + - Extending the Scaffolder: + - Overview: 'features/software-templates/extending/index.md' + - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' + - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' + - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Docs-like-code: + - Overview: 'features/techdocs/README.md' + - Getting Started: 'features/techdocs/getting-started.md' + - Concepts: 'features/techdocs/concepts.md' + - Reading Documentation: 'features/techdocs/reading-documentation.md' + - Writing Documentation: 'features/techdocs/writing-documentation.md' + - Publishing Documentation: 'features/techdocs/publishing-documentation.md' + - Contributing: 'features/techdocs/contributing.md' + - Debugging: 'features/techdocs/debugging.md' + - FAQ: 'features/techdocs/FAQ.md' + - Plugins: + - Overview: 'plugins/index.md' + - Existing plugins: 'plugins/existing-plugins.md' + - Creating a new plugin: 'plugins/create-a-plugin.md' + - Developing a plugin: 'plugins/plugin-development.md' + - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Backends and APIs: + - Proxying: 'plugins/proxying.md' + - Backstage backend plugin: 'plugins/backend-plugin.md' + - Call existing API: 'plugins/call-existing-api.md' + - Testing: + - Overview: 'plugins/testing.md' + - Publishing: + - Open source and NPM: 'plugins/publishing.md' + - Private/internal (non-open source): 'plugins/publish-private.md' + - Authentication and identity: + - Overview: 'auth/index.md' + - Add auth provider: 'auth/add-auth-provider.md' + - Auth backend: 'auth/auth-backend.md' + - OAuth: 'auth/oauth.md' + - Glossary: 'auth/glossary.md' + - Designing for Backstage: + - Backstage Design Language System (DLS): 'dls/design.md' + - Storybook -- reusable UI components: 'http://storybook.backstage.io' + - Contributing to Storybook: 'dls/contributing-to-storybook.md' + - Figma resources: 'dls/figma.md' + - API references: + - TypeScript APIs: + - Utilities: 'api/utility-apis.md' + - createPlugin: 'reference/createPlugin.md' + - createPlugin-feature-flags: 'reference/createPlugin-feature-flags.md' + - createPlugin-router: 'reference/createPlugin-router.md' + - Backend APIs: + - Backend: 'api/backend.md' + - Tutorials: + - Overview: 'tutorials/index.md' + - Architecture Decision Records (ADRs): + - Overview: 'architecture-decisions/index.md' + - ADR001 - Architecture Decision Record (ADR) log: 'architecture-decisions/adr001-add-adr-log.md' + - ADR002 - Default Software Catalog File Format: 'architecture-decisions/adr002-default-catalog-file-format.md' + - ADR003 - Avoid Default Exports and Prefer Named Exports: 'architecture-decisions/adr003-avoid-default-exports.md' + - ADR004 - Module Export Structure: 'architecture-decisions/adr004-module-export-structure.md' + - ADR005 - Catalog Core Entities: 'architecture-decisions/adr005-catalog-core-entities.md' + - ADR006 - Avoid React.FC and React.SFC: 'architecture-decisions/adr006-avoid-react-fc.md' + - ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md' + - Contribute: '../CONTRIBUTING.md' + - Support: 'overview/support.md' + - FAQ: FAQ.md + +plugins: + - techdocs-core diff --git a/package.json b/package.json index 125dc285aa..4bc4b180dd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ }, "scripts": { "start": "yarn workspace example-app start", - "bundle": "yarn workspace example-app bundle", "build": "lerna run build", "tsc": "tsc", "clean": "backstage-cli clean && lerna run clean", @@ -16,7 +15,9 @@ "lint": "lerna run lint --since origin/master --", "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", - "docker-build": "yarn bundle && docker build . -t spotify/backstage", + "docgen": "lerna run docgen", + "docker-build": "yarn workspace example-app build && docker build . -t spotify/backstage", + "docker-build:all": "yarn tsc && yarn build && yarn docker-build && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi", diff --git a/packages/app/package.json b/packages/app/package.json index 88e2e630a7..168506d498 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,29 +1,29 @@ { "name": "example-app", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/plugin-catalog": "^0.1.1-alpha.13", - "@backstage/plugin-circleci": "^0.1.1-alpha.13", - "@backstage/plugin-explore": "^0.1.1-alpha.13", - "@backstage/plugin-gcp-projects": "^0.1.1-alpha.13", - "@backstage/plugin-github-actions": "^0.1.1-alpha.13", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.13", - "@backstage/plugin-graphiql": "^0.1.1-alpha.13", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.13", - "@backstage/plugin-register-component": "^0.1.1-alpha.13", - "@backstage/plugin-rollbar": "^0.1.1-alpha.13", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.13", - "@backstage/plugin-sentry": "^0.1.1-alpha.13", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.13", - "@backstage/plugin-techdocs": "^0.1.1-alpha.13", - "@backstage/plugin-welcome": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/plugin-catalog": "^0.1.1-alpha.16", + "@backstage/plugin-circleci": "^0.1.1-alpha.16", + "@backstage/plugin-explore": "^0.1.1-alpha.16", + "@backstage/plugin-github-actions": "^0.1.1-alpha.16", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.16", + "@backstage/plugin-graphiql": "^0.1.1-alpha.16", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.16", + "@backstage/plugin-register-component": "^0.1.1-alpha.16", + "@backstage/plugin-rollbar": "^0.1.1-alpha.16", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.16", + "@backstage/plugin-sentry": "^0.1.1-alpha.16", + "@backstage/plugin-tech-radar": "^0.1.1-alpha.16", + "@backstage/plugin-techdocs": "^0.1.1-alpha.16", + "@backstage/plugin-welcome": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", + "@octokit/rest": "^18.0.0", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", @@ -39,7 +39,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/jquery": "^3.3.34", "@types/node": "^12.0.0", "@types/react-dom": "^16.9.8", @@ -51,7 +51,7 @@ }, "scripts": { "start": "backstage-cli app:serve", - "bundle": "backstage-cli app:build", + "build": "backstage-cli app:build", "clean": "backstage-cli clean", "test": "backstage-cli test", "test:e2e": "start-server-and-test start http://localhost:3000 cy:dev", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3c8054d995..9e9ca1edd0 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -19,6 +19,8 @@ import { AlertDisplay, OAuthRequestDialog, SignInPage, + useApi, + configApiRef, } from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; @@ -30,12 +32,13 @@ const app = createApp({ apis, plugins: Object.values(plugins), components: { - SignInPage: props => ( - - ), + SignInPage: props => { + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const providers = providersConfig?.keys() ?? []; + + return ; + }, }, }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index bcb09ce4b5..46f05d0e01 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -86,7 +86,9 @@ export const apis = (config: ConfigApi) => { circleCIApiRef, new CircleCIApi(`${backendUrl}/proxy/circleci/api`), ); + builder.add(githubActionsApiRef, new GithubActionsClient()); + builder.add(featureFlagsApiRef, new FeatureFlags()); builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index aa856526cc..18131236df 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.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,19 +29,20 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", + "express-promise-router": "^3.0.3", "helmet": "^3.22.0", "morgan": "^1.10.0", "stoppable": "^1.1.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index e8a5a7f47a..a7a3d64bd1 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -96,4 +96,38 @@ describe('errorHandler', () => { expect((await r.get('/NotFoundError')).status).toBe(404); expect((await r.get('/ConflictError')).status).toBe(409); }); + + it('logs all 500 errors', async () => { + const app = express(); + + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + + const thrownError = new Error('some error'); + + app.use('/breaks', () => { + throw thrownError; + }); + app.use(errorHandler({ logger: mockLogger as any })); + + await request(app).get('/breaks'); + + expect(mockLogger.error).toHaveBeenCalledWith(thrownError); + }); + + it('does not log 400 errors', async () => { + const app = express(); + + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + + app.use('/NotFound', () => { + throw new errors.NotFoundError(); + }); + app.use(errorHandler({ logger: mockLogger as any })); + + await request(app).get('/NotFound'); + + expect(mockLogger.error).not.toHaveBeenCalled(); + }); }); diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index ad8f170dcd..7365ce8b93 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -15,7 +15,9 @@ */ import { ErrorRequestHandler, NextFunction, Request, Response } from 'express'; +import { Logger } from 'winston'; import * as errors from '../errors'; +import { getRootLogger } from '../logging'; export type ErrorHandlerOptions = { /** @@ -24,6 +26,13 @@ export type ErrorHandlerOptions = { * If not specified, by default shows stack traces only in development mode. */ showStackTraces?: boolean; + + /** + * Logger instance to log any 5xx errors. + * + * If not specified, the root logger will be used. + */ + logger?: Logger; }; /** @@ -39,12 +48,17 @@ export type ErrorHandlerOptions = { * * @returns An Express error request handler */ + export function errorHandler( options: ErrorHandlerOptions = {}, ): ErrorRequestHandler { const showStackTraces = options.showStackTraces ?? process.env.NODE_ENV === 'development'; + const logger = (options.logger || getRootLogger()).child({ + type: 'errorHandler', + }); + /* eslint-disable @typescript-eslint/no-unused-vars */ return ( error: Error, @@ -61,6 +75,11 @@ export function errorHandler( const status = getStatusCode(error); const message = showStackTraces ? error.stack : error.message; + + if (logger && status >= 500) { + logger.error(error); + } + response.status(status).send(message); }; } diff --git a/packages/backend-common/src/middleware/index.ts b/packages/backend-common/src/middleware/index.ts index 083b36c3e9..76c52d8830 100644 --- a/packages/backend-common/src/middleware/index.ts +++ b/packages/backend-common/src/middleware/index.ts @@ -17,3 +17,4 @@ export * from './errorHandler'; export * from './notFoundHandler'; export * from './requestLoggingHandler'; +export * from './statusCheckHandler'; diff --git a/packages/backend-common/src/middleware/statusCheckHandler.test.ts b/packages/backend-common/src/middleware/statusCheckHandler.test.ts new file mode 100644 index 0000000000..7ed1a65b58 --- /dev/null +++ b/packages/backend-common/src/middleware/statusCheckHandler.test.ts @@ -0,0 +1,55 @@ +/* + * 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 express from 'express'; +import request from 'supertest'; +import { statusCheckHandler } from './statusCheckHandler'; + +describe('statusCheckHandler', () => { + it('gives status 200 when using default', async () => { + const app = express(); + app.use('/healthcheck', await statusCheckHandler()); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 200 when status function returns true', async () => { + const app = express(); + const status = { foo: 'bar' }; + const statusCheck = () => Promise.resolve(status); + app.use('/healthcheck', await statusCheckHandler({ statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify(status)); + }); + + it('gives status 500 when status check throws an error', async () => { + const app = express(); + const statusCheck = () => { + throw Error('error!'); + }; + app.use('/healthcheck', await statusCheckHandler({ statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(500); + }); +}); diff --git a/packages/backend-common/src/middleware/statusCheckHandler.ts b/packages/backend-common/src/middleware/statusCheckHandler.ts new file mode 100644 index 0000000000..3f62f04f59 --- /dev/null +++ b/packages/backend-common/src/middleware/statusCheckHandler.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 { NextFunction, Request, Response, RequestHandler } from 'express'; + +export type StatusCheck = () => Promise; + +export interface StatusCheckHandlerOptions { + /** + * Optional status function which returns a message. + */ + statusCheck?: StatusCheck; +} + +/** + * Express middleware for status checks. + * + * This is commonly used to implement healthcheck and readiness routes. + * + * @param options An optional configuration object. + * @returns An Express error request handler + */ +export async function statusCheckHandler( + options: StatusCheckHandlerOptions = {}, +): Promise { + const statusCheck: StatusCheck = options.statusCheck + ? options.statusCheck + : () => Promise.resolve({ status: 'ok' }); + + return async (_request: Request, response: Response, next: NextFunction) => { + try { + const status = await statusCheck(); + response.status(200).header('').send(status); + } catch (err) { + next(err); + } + }; +} diff --git a/packages/backend-common/src/service/createStatusCheckRouter.test.ts b/packages/backend-common/src/service/createStatusCheckRouter.test.ts new file mode 100644 index 0000000000..6c15e7b5f9 --- /dev/null +++ b/packages/backend-common/src/service/createStatusCheckRouter.test.ts @@ -0,0 +1,56 @@ +/* + * 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 express from 'express'; +import * as winston from 'winston'; +import request from 'supertest'; +import { createStatusCheckRouter } from './createStatusCheckRouter'; + +describe('createStatusCheckRouter', () => { + const logger = winston.createLogger(); + + it('gives status 200 when using default path', async () => { + const app = express(); + app.use('', await createStatusCheckRouter({ logger })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 200 when using custom path', async () => { + const app = express(); + app.use('', await createStatusCheckRouter({ logger, path: '/ready' })); + + const response = await request(app).get('/ready'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 500 when status check throws an error', async () => { + const app = express(); + const statusCheck = () => { + throw Error('error!'); + }; + app.use('', await createStatusCheckRouter({ logger, statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(500); + }); +}); diff --git a/packages/backend-common/src/service/createStatusCheckRouter.ts b/packages/backend-common/src/service/createStatusCheckRouter.ts new file mode 100644 index 0000000000..c6014cbfc2 --- /dev/null +++ b/packages/backend-common/src/service/createStatusCheckRouter.ts @@ -0,0 +1,38 @@ +/* + * 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 { Logger } from 'winston'; +import Router from 'express-promise-router'; +import express from 'express'; +import { errorHandler, statusCheckHandler, StatusCheck } from '../middleware'; + +export interface StatusCheckRouterOptions { + logger: Logger; + path?: string; + statusCheck?: StatusCheck; +} + +export async function createStatusCheckRouter( + options: StatusCheckRouterOptions, +): Promise { + const router = Router(); + const { path = '/healthcheck', statusCheck } = options; + + router.use(path, await statusCheckHandler({ statusCheck })); + router.use(errorHandler()); + + return router; +} diff --git a/packages/backend-common/src/service/index.ts b/packages/backend-common/src/service/index.ts index 9bba9d6baf..58e310032d 100644 --- a/packages/backend-common/src/service/index.ts +++ b/packages/backend-common/src/service/index.ts @@ -15,4 +15,5 @@ */ export { createServiceBuilder } from './createServiceBuilder'; +export { createStatusCheckRouter } from './createStatusCheckRouter'; export type { ServiceBuilder } from './types'; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 19418b2671..4a51fb195c 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -45,9 +45,9 @@ export class ServiceBuilderImpl implements ServiceBuilder { // reloading private module: NodeModule; - constructor(module: NodeModule) { + constructor(moduleRef: NodeModule) { this.routers = []; - this.module = module; + this.module = moduleRef; } loadConfig(config: ConfigReader): ServiceBuilder { diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index b39df27527..070794969f 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -16,7 +16,7 @@ import { ConfigReader } from '@backstage/config'; import cors from 'cors'; -import { Router } from 'express'; +import { Router, RequestHandler } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -64,7 +64,7 @@ export type ServiceBuilder = { * @param root The root URL to bind to (e.g. "/api/function1") * @param router An express router */ - addRouter(root: string, router: Router): ServiceBuilder; + addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; /** * Starts the server using the given settings. diff --git a/packages/backend/README.md b/packages/backend/README.md index 47574e12af..90c70912f0 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -29,6 +29,7 @@ AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \ AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \ AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \ AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \ +ROLLBAR_ACCOUNT_TOKEN=x \ SENTRY_TOKEN=x \ LOG_LEVEL=debug \ yarn start diff --git a/packages/backend/package.json b/packages/backend/package.json index 9e42f6c4cb..7ca11fc636 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -18,27 +18,29 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/config-loader": "^0.1.1-alpha.13", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.13", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.13", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.13", - "@backstage/plugin-proxy-backend": "^0.1.1-alpha.13", - "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.13", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.13", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.13", - "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/config-loader": "^0.1.1-alpha.16", + "@backstage/plugin-auth-backend": "^0.1.1-alpha.16", + "@backstage/plugin-catalog-backend": "^0.1.1-alpha.16", + "@backstage/plugin-identity-backend": "^0.1.1-alpha.16", + "@backstage/plugin-proxy-backend": "^0.1.1-alpha.16", + "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.16", + "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.16", + "@backstage/plugin-sentry-backend": "^0.1.1-alpha.16", + "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.16", + "@backstage/plugin-graphql-backend": "^0.1.1-alpha.16", "@octokit/rest": "^18.0.0", "dockerode": "^3.2.0", "express": "^4.17.1", "knex": "^0.21.1", + "pg": "^8.3.0", "sqlite3": "^4.2.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d060791f98..3fccfe9c59 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -29,7 +29,8 @@ import { } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; import { loadConfig } from '@backstage/config-loader'; -import knex from 'knex'; +import knex, { PgConnectionConfig } from 'knex'; +import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import identity from './plugins/identity'; @@ -38,6 +39,7 @@ import scaffolder from './plugins/scaffolder'; import sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; +import graphql from './plugins/graphql'; import { PluginEnvironment } from './types'; function makeCreateEnv(loadedConfigs: AppConfig[]) { @@ -45,11 +47,36 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { return (plugin: string): PluginEnvironment => { const logger = getRootLogger().child({ type: 'plugin', plugin }); - const database = knex({ - client: 'sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); + // Supported DBs are sqlite and postgres + const isPg = [ + 'POSTGRES_USER', + 'POSTGRES_HOST', + 'POSTGRES_PASSWORD', + ].every(key => config.getOptional(`backend.${key}`)); + + let knexConfig; + + if (isPg) { + knexConfig = { + client: 'pg', + useNullAsDefault: true, + connection: { + port: config.getOptionalNumber('backend.POSTGRES_PORT'), + host: config.getString('backend.POSTGRES_HOST'), + user: config.getString('backend.POSTGRES_USER'), + password: config.getString('backend.POSTGRES_PASSWORD'), + database: `backstage_plugin_${plugin}`, + } as PgConnectionConfig, + }; + } else { + knexConfig = { + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }; + } + + const database = knex(knexConfig); database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { resource.run('PRAGMA foreign_keys = ON', () => {}); }); @@ -58,10 +85,11 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { } async function main() { - const configs = await loadConfig(); + const configs = await loadConfig({ shouldReadSecrets: true }); const configReader = ConfigReader.fromConfigs(configs); const createEnv = makeCreateEnv(configs); + const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); @@ -70,9 +98,11 @@ async function main() { const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const service = createServiceBuilder(module) .loadConfig(configReader) + .addRouter('', await healthcheck(healthcheckEnv)) .addRouter('/catalog', await catalog(catalogEnv)) .addRouter('/rollbar', await rollbar(rollbarEnv)) .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) @@ -80,7 +110,8 @@ async function main() { .addRouter('/auth', await auth(authEnv)) .addRouter('/identity', await identity(identityEnv)) .addRouter('/techdocs', await techdocs(techdocsEnv)) - .addRouter('/proxy', await proxy(proxyEnv)); + .addRouter('/proxy', await proxy(proxyEnv)) + .addRouter('/graphql', await graphql(graphqlEnv)); await service.start().catch(err => { console.log(err); diff --git a/packages/backend/src/plugins/graphql.ts b/packages/backend/src/plugins/graphql.ts new file mode 100644 index 0000000000..0f9b167d19 --- /dev/null +++ b/packages/backend/src/plugins/graphql.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. + */ +/* + * 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 { createRouter } from '@backstage/plugin-graphql-backend'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + return await createRouter({ + logger, + }); +} diff --git a/packages/backend/src/plugins/healthcheck.ts b/packages/backend/src/plugins/healthcheck.ts new file mode 100644 index 0000000000..3dd9a67827 --- /dev/null +++ b/packages/backend/src/plugins/healthcheck.ts @@ -0,0 +1,22 @@ +/* + * 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 { createStatusCheckRouter } from '@backstage/backend-common'; +import { PluginEnvironment } from '../types'; + +export default async function createRouter({ logger }: PluginEnvironment) { + return await createStatusCheckRouter({ logger, path: '/healthcheck' }); +} diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 70f3d88f3e..a9e5f185ec 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -21,6 +21,7 @@ import { GithubPreparer, Preparers, GithubPublisher, + CreateReactAppTemplater, Templaters, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; @@ -29,8 +30,10 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); + const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); const filePreparer = new FilePreparer(); const githubPreparer = new GithubPreparer(); diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 760861f05a..af351842ce 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", "@types/json-schema": "^7.0.5", "@types/yup": "^0.28.2", "json-schema": "^0.2.5", @@ -29,9 +29,9 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/express": "^4.17.6", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" }, diff --git a/packages/cli/asset-types/asset-types.d.ts b/packages/cli/asset-types/asset-types.d.ts index 1763d256f5..28c1fde9e3 100644 --- a/packages/cli/asset-types/asset-types.d.ts +++ b/packages/cli/asset-types/asset-types.d.ts @@ -50,6 +50,11 @@ declare module '*.webp' { export default src; } +declare module '*.yaml' { + const src: string; + export default src; +} + declare module '*.icon.svg' { import { ComponentType } from 'react'; import { SvgIconProps } from '@material-ui/core'; diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 80e0bcf26c..da9b3ec3e6 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -38,15 +38,18 @@ async function getConfig() { transform: { '\\.esm\\.js$': require.resolve('jest-esm-transformer'), '\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'), - '\\.(bmp|gif|jpe|png|frag|xml|svg)': require.resolve( + '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)': require.resolve( './jestFileTransform.js', ), }, + // A bit more opinionated + testMatch: ['**/?(*.)test.{js,jsx,mjs,ts,tsx}'], + // Default behaviour is to not apply transforms for node_modules, but we still want // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. transformIgnorePatterns: [ - '/node_modules/(?!.*\\.(?:esm\\.js|bmp|gif|jpe|png|frag|xml|svg)$)', + '/node_modules/(?!.*\\.(?:esm\\.js|bmp|gif|jpg|jpeg|png|frag|xml|svg)$)', ], }; @@ -55,13 +58,37 @@ async function getConfig() { options.setupFilesAfterEnv = ['/setupTests.ts']; } - return { - ...options, + // We read all "jest" config fields in package.json files all the way to the filesystem root. + // All configs are merged together to create the final config, with longer paths taking precedence. + // The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined. + const pkgJsonConfigs = []; + let currentPath = process.cwd(); - // If the package has a jest object in package.json we merge that config in. This is the recommended - // location for configuring tests. - ...require(path.resolve('package.json')).jest, - }; + // Some sanity check to avoid infinite loop + for (let i = 0; i < 100; i++) { + const packagePath = path.resolve(currentPath, 'package.json'); + const exists = fs.pathExistsSync(packagePath); + if (exists) { + try { + const data = fs.readJsonSync(packagePath); + if (data.jest) { + pkgJsonConfigs.unshift(data.jest); + } + } catch (error) { + throw new Error( + `Failed to parse package.json file reading jest configs, ${error}`, + ); + } + } + + const newPath = path.dirname(currentPath); + if (newPath === currentPath) { + break; + } + currentPath = newPath; + } + + return Object.assign(options, ...pkgJsonConfigs); } module.exports = getConfig(); diff --git a/packages/cli/e2e-test/cli-e2e-test.js b/packages/cli/e2e-test/cli-e2e-test.js index ef5d6ab243..206ea25ccd 100644 --- a/packages/cli/e2e-test/cli-e2e-test.js +++ b/packages/cli/e2e-test/cli-e2e-test.js @@ -16,6 +16,7 @@ const os = require('os'); const fs = require('fs-extra'); +const fetch = require('node-fetch'); const killTree = require('tree-kill'); const { resolve: resolvePath, join: joinPath } = require('path'); const Browser = require('zombie'); @@ -28,6 +29,7 @@ const { waitForExit, print, } = require('./helpers'); +const pgtools = require('pgtools'); async function main() { const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); @@ -36,8 +38,9 @@ async function main() { print('Building dist workspace'); const workspaceDir = await buildDistWorkspace('workspace', rootDir); + const isPostgres = Boolean(process.env.POSTGRES_USER); print('Creating a Backstage App'); - const appDir = await createApp('test-app', workspaceDir, rootDir); + const appDir = await createApp('test-app', isPostgres, workspaceDir, rootDir); print('Creating a Backstage Plugin'); const pluginName = await createPlugin('test-plugin', appDir); @@ -45,6 +48,9 @@ async function main() { print('Starting the app'); await testAppServe(pluginName, appDir); + print('Testing the backend startup'); + await testBackendStart(appDir, isPostgres); + print('All tests successful, removing test dir'); await fs.remove(rootDir); } @@ -97,7 +103,7 @@ async function pinYarnVersion(dir) { /** * Creates a new app inside rootDir called test-app, using packages from the workspaceDir */ -async function createApp(appName, workspaceDir, rootDir) { +async function createApp(appName, isPostgres, workspaceDir, rootDir) { const child = spawnPiped( [ 'node', @@ -119,6 +125,14 @@ async function createApp(appName, workspaceDir, rootDir) { await waitFor(() => stdout.includes('Enter a name for the app')); child.stdin.write(`${appName}\n`); + await waitFor(() => stdout.includes('Select database for the backend')); + + if (!isPostgres) { + // Simulate down arrow press + child.stdin.write(`\u001B\u005B\u0042`); + } + child.stdin.write(`\n`); + print('Waiting for app create script to be done'); await waitForExit(child); @@ -201,7 +215,7 @@ async function createPlugin(pluginName, appDir) { await waitForExit(child); const pluginDir = resolvePath(appDir, 'plugins', pluginName); - for (const cmd of [['lint'], ['test', '--no-watch']]) { + for (const cmd of [['tsc'], ['lint'], ['test', '--no-watch']]) { print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`); await runPlain(['yarn', ...cmd], { cwd: pluginDir }); } @@ -250,5 +264,87 @@ async function testAppServe(pluginName, appDir) { } } +/** Creates PG databases (drops if exists before) */ +async function createDB(database) { + const config = { + host: process.env.POSTGRES_HOST, + port: process.env.POSTGRES_PORT, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + }; + + try { + await pgtools.dropdb({ config }, database); + } catch (_) { + /* do nothing*/ + } + return pgtools.createdb(config, database); +} + +/** + * Start serving the newly created backend and make sure that all db migrations works correctly + */ +async function testBackendStart(appDir, isPostgres) { + if (isPostgres) { + print('Creating DBs'); + await Promise.all( + [ + 'catalog', + 'scaffolder', + 'auth', + 'identity', + 'proxy', + 'techdocs', + ].map(name => createDB(`backstage_plugin_${name}`)), + ); + print('Created DBs'); + } + + const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], { + cwd: appDir, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + child.stderr.on('data', data => { + stderr = stderr + data.toString('utf8'); + }); + let successful = false; + + try { + await waitFor(() => stdout.includes('Listening on ') || stderr !== ''); + if (stderr !== '') { + // Skipping the whole block + throw new Error(stderr); + } + + print('Try to fetch entities from the backend'); + // Try fetch entities, should be ok + await fetch('http://localhost:7000/catalog/entities').then(res => + res.json(), + ); + print('Entities fetched successfully'); + successful = true; + } catch (error) { + throw new Error(`Backend failed to startup: ${error}`); + } finally { + print('Stopping the child process'); + // Kill entire process group, otherwise we'll end up with hanging serve processes + killTree(child.pid); + } + + try { + await waitForExit(child); + } catch (error) { + if (!successful) { + throw new Error(`Backend failed to startup: ${stderr}`); + } + print('Backend startup test finished successfully'); + } +} + process.on('unhandledRejection', handleError); main(process.argv.slice(2)).catch(handleError); diff --git a/packages/cli/e2e-test/helpers.js b/packages/cli/e2e-test/helpers.js index da2ec343ab..84807119c3 100644 --- a/packages/cli/e2e-test/helpers.js +++ b/packages/cli/e2e-test/helpers.js @@ -76,6 +76,12 @@ function handleError(err) { } } +/** + * Waits for fn() to be true + * Checks every 100ms + * .cancel() is available + * @returns {Promise} Promise of resolution + */ function waitFor(fn) { return new Promise(resolve => { const handle = setInterval(() => { diff --git a/packages/cli/package.json b/packages/cli/package.json index df0140f1ec..348e9c3a16 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.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public" @@ -29,14 +29,15 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/config-loader": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/config-loader": "^0.1.1-alpha.16", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", "@rollup/plugin-commonjs": "^13.0.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^8.1.0", + "@rollup/plugin-yaml": "^2.1.1", "@spotify/eslint-config": "^7.0.1", "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "4.3.x", @@ -53,7 +54,9 @@ "css-loader": "^3.5.3", "dashify": "^2.0.0", "diff": "^4.0.2", + "esbuild": "0.6.3", "eslint": "^7.1.0", + "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-monorepo": "^0.2.1", "fork-ts-checker-webpack-plugin": "^4.0.5", @@ -65,14 +68,16 @@ "jest-css-modules": "^2.1.0", "jest-esm-transformer": "^1.0.0", "mini-css-extract-plugin": "^0.9.0", + "node-fetch": "^2.6.0", "ora": "^4.0.3", + "pgtools": "^0.3.0", "raw-loader": "^4.0.1", "react": "^16.0.0", "react-dev-utils": "^10.2.1", "react-hot-loader": "^4.12.21", "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", - "rollup": "2.10.x", + "rollup": "2.23.x", "rollup-plugin-dts": "^1.4.6", "rollup-plugin-esbuild": "^2.0.0", "rollup-plugin-image-files": "^1.4.2", diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index a847f1ef54..258ef102d7 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -19,8 +19,12 @@ import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { run } from '../../lib/run'; +const PKG_PATH = 'package.json'; + export default async (imageTag: string) => { - const tempDistWorkspace = await createDistWorkspace(['example-backend'], { + const pkgPath = paths.resolveTarget(PKG_PATH); + const pkg = await fs.readJson(pkgPath); + const tempDistWorkspace = await createDistWorkspace([pkg.name], { files: [ 'package.json', 'yarn.lock', @@ -28,7 +32,6 @@ export default async (imageTag: string) => { { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, ], }); - console.log(`Dist workspace ready at ${tempDistWorkspace}`); await run('docker', ['build', '.', '-t', imageTag], { diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 4f4134957d..b3ddcd3109 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -104,8 +104,17 @@ export default async (cmd: Command): Promise => { return true; }, }, + { + type: 'list', + name: 'dbType', + message: chalk.blue('Select database for the backend [required]'), + // @ts-ignore + choices: ['PostgreSQL', 'SQLite'], + }, ]; const answers: Answers = await inquirer.prompt(questions); + answers.dbTypePG = answers.dbType === 'PostgreSQL'; + answers.dbTypeSqlite = answers.dbType === 'SQLite'; const templateDir = paths.resolveOwn('templates/default-app'); const tempDir = resolvePath(os.tmpdir(), answers.name); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 41e69c51be..b355939e55 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -18,12 +18,12 @@ import { Command } from 'commander'; import { run } from '../lib/run'; import { paths } from '../lib/paths'; -export default async (cmd: Command) => { +export default async (cmd: Command, cmdArgs: string[]) => { const args = [ '--ext=js,jsx,ts,tsx', '--max-warnings=0', - '--format=codeframe', - paths.targetDir, + '--format=eslint-formatter-friendly', + ...(cmdArgs ?? [paths.targetDir]), ]; if (cmd.fix) { args.push('--fix'); diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 5987290604..e62e1fc6cd 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -26,6 +26,7 @@ import imageFiles from 'rollup-plugin-image-files'; import svgr from '@svgr/rollup'; import dts from 'rollup-plugin-dts'; import json from '@rollup/plugin-json'; +import yaml from '@rollup/plugin-yaml'; import { RollupOptions, OutputOptions } from 'rollup'; import { BuildOptions, Output } from './types'; @@ -93,6 +94,7 @@ export const makeConfigs = async ( postcss(), imageFiles({ exclude: '**/*.icon.svg' }), json(), + yaml(), svgr({ include: '**/*.icon.svg', template: svgrTemplate, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 0eaac9ad2d..56150ad968 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -25,11 +25,6 @@ import { Config } from '@backstage/config'; import { BundlingPaths } from './paths'; import { transforms } from './transforms'; import { BundlingOptions, BackendBundlingOptions } from './types'; -// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles'; -// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin'; -// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware'; -// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware'; -// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin'; export function resolveBaseUrl(config: Config): URL { const baseUrl = config.getString('app.baseUrl'); @@ -126,10 +121,10 @@ export function createConfig( output: { path: paths.targetDist, publicPath: validBaseUrl.pathname, - filename: isDev ? '[name].js' : '[name].[hash:8].js', + filename: isDev ? '[name].js' : 'static/[name].[hash:8].js', chunkFilename: isDev ? '[name].chunk.js' - : '[name].[chunkhash:8].chunk.js', + : 'static/[name].[chunkhash:8].chunk.js', }, plugins, }; diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index 03168b376d..acd6a937d9 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -46,7 +46,7 @@ export const optimization = ( }, filename: isDev ? 'module-[name].js' - : 'module-[name].[chunkhash:8].js', + : 'static/module-[name].[chunkhash:8].js', priority: 10, minSize: 100000, minChunks: 1, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 5f881bd53c..d8186dac66 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -29,13 +29,16 @@ export const transforms = ( ): Transforms => { const { isDev } = options; + const extraTransforms = isDev ? ['react-hot-loader'] : []; + const loaders = [ { test: /\.(tsx?)$/, exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { - transforms: ['typescript', 'jsx', 'react-hot-loader'], + transforms: ['typescript', 'jsx', ...extraTransforms], + production: !isDev, }, }, { @@ -43,7 +46,8 @@ export const transforms = ( exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { - transforms: ['jsx', 'react-hot-loader'], + transforms: ['jsx', ...extraTransforms], + production: !isDev, }, }, { @@ -51,7 +55,10 @@ export const transforms = ( use: [ { loader: require.resolve('@sucrase/webpack-loader'), - options: { transforms: ['jsx'] }, + options: { + transforms: ['jsx', ...extraTransforms], + production: !isDev, + }, }, { loader: require.resolve('@svgr/webpack'), @@ -72,7 +79,7 @@ export const transforms = ( loader: require.resolve('url-loader'), options: { limit: 10000, - name: 'static/media/[name].[hash:8].[ext]', + name: 'static/[name].[hash:8].[ext]', }, }, { @@ -104,8 +111,8 @@ export const transforms = ( } else { plugins.push( new MiniCssExtractPlugin({ - filename: '[name].[contenthash:8].css', - chunkFilename: '[name].[id].[contenthash:8].css', + filename: 'static/[name].[contenthash:8].css', + chunkFilename: 'static/[name].[id].[contenthash:8].css', }), ); } diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index d3ba1f6b12..f4a2181b40 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -134,11 +134,15 @@ async function findTargetPackages(pkgNames: string[]): Promise { throw new Error(`Package '${name}' not found`); } - const pkgDeps = Object.keys(node.pkg.dependencies); - const localDeps: string[] = Array.from(node.localDependencies.keys()); - const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep)); + // Don't include dependencies of packages that are marked as bundled + if (!node.pkg.get('bundled')) { + const pkgDeps = Object.keys(node.pkg.dependencies); + const localDeps: string[] = Array.from(node.localDependencies.keys()); + const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep)); + + searchNames.push(...filteredDeps); + } - searchNames.push(...filteredDeps); targets.set(name, node.pkg); } diff --git a/packages/cli/src/lib/paths.ts b/packages/cli/src/lib/paths.ts index be3f3dcee8..ab3578ca1c 100644 --- a/packages/cli/src/lib/paths.ts +++ b/packages/cli/src/lib/paths.ts @@ -47,7 +47,7 @@ export type Paths = { resolveTargetRoot: ResolveFunc; }; -// Looks for a package.json that has name: "root" to identify the root of the monorepo +// Looks for a package.json with a workspace config to identify the root of the monorepo export function findRootPath(topPath: string): string { let path = topPath; @@ -58,7 +58,7 @@ export function findRootPath(topPath: string): string { if (exists) { try { const data = fs.readJsonSync(packagePath); - if (data.name === 'root' || data.name.includes('backstage-e2e')) { + if (data.workspaces?.packages) { return path; } } catch (error) { @@ -70,9 +70,7 @@ export function findRootPath(topPath: string): string { const newPath = dirname(path); if (newPath === path) { - throw new Error( - `No package.json with name "root" found as a parent of ${topPath}`, - ); + return topPath; // We didn't find any root package.json, assume we're not in a monorepo } path = newPath; } diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 15b827f0c6..69d3d92640 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -92,7 +92,7 @@ export async function runCheck(cmd: string, ...args: string[]) { } export async function waitForExit( - child: ChildProcess & { exitCode?: number }, + child: ChildProcess & { exitCode: number | null }, name?: string, ): Promise { if (typeof child.exitCode === 'number') { diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts index 8cb5a1e7df..94136f1dbc 100644 --- a/packages/cli/src/types.d.ts +++ b/packages/cli/src/types.d.ts @@ -27,3 +27,5 @@ declare module 'rollup-plugin-image-files' { declare module '@svgr/rollup' { export default function svgr(options?: any): any; } + +declare module '@rollup/plugin-yaml'; diff --git a/packages/cli/templates/default-app/.eslintrc.js b/packages/cli/templates/default-app/.eslintrc.js index 13573efa9c..e351352491 100644 --- a/packages/cli/templates/default-app/.eslintrc.js +++ b/packages/cli/templates/default-app/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: [require.resolve('@backstage/cli/config/eslint')], + root: true, }; diff --git a/packages/cli/templates/default-app/app-config.yaml b/packages/cli/templates/default-app/app-config.yaml index 58f7b795ff..0d51cf88ef 100644 --- a/packages/cli/templates/default-app/app-config.yaml +++ b/packages/cli/templates/default-app/app-config.yaml @@ -4,3 +4,19 @@ app: organization: name: Acme Corporation + +backend: + baseUrl: http://localhost:7000 + listen: 0.0.0.0:7000 + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true + +proxy: + '/test': + target: 'https://example.com' + changeOrigin: true + +techdocs: + storageUrl: https://techdocs-mock-sites.storage.googleapis.com diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index 1414ef341a..b59ca63fa3 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -7,7 +7,6 @@ }, "scripts": { "start": "yarn workspace app start", - "bundle": "yarn workspace app bundle", "build": "lerna run build", "tsc": "tsc", "clean": "backstage-cli clean && lerna run clean", @@ -32,7 +31,7 @@ "prettier": "^1.19.1" }, "resolutions": { - "**/esbuild": "0.5.3" + "esbuild": "0.6.3" }, "prettier": "@spotify/prettier-config", "lint-staged": { diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index 26cce2c5e6..f29453b6dc 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -22,7 +22,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-dom": "^16.9.8", "cross-env": "^7.0.0", @@ -32,7 +32,7 @@ }, "scripts": { "start": "backstage-cli app:serve", - "bundle": "backstage-cli app:build", + "build": "backstage-cli app:build", "test": "backstage-cli test", "lint": "backstage-cli lint", "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", diff --git a/packages/cli/templates/default-app/packages/backend/.eslintrc.js b/packages/cli/templates/default-app/packages/backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/cli/templates/default-app/packages/backend/Dockerfile b/packages/cli/templates/default-app/packages/backend/Dockerfile new file mode 100644 index 0000000000..3e8ba36cec --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM node:12 + +WORKDIR /usr/src/app + +COPY . . + +RUN yarn install --frozen-lockfile --production + +CMD ["node", "packages/backend"] diff --git a/packages/cli/templates/default-app/packages/backend/README.md b/packages/cli/templates/default-app/packages/backend/README.md new file mode 100644 index 0000000000..f94904a930 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/README.md @@ -0,0 +1,68 @@ +# example-backend + +This package is an EXAMPLE of a Backstage backend. + +The main purpose of this package is to provide a test bed for Backstage plugins +that have a backend part. Feel free to experiment locally or within your fork +by adding dependencies and routes to this backend, to try things out. + +Our goal is to eventually amend the create-app flow of the CLI, such that a +production ready version of a backend skeleton is made alongside the frontend +app. Until then, feel free to experiment here! + +## Development + +To run the example backend, first go to the project root and run + +```bash +yarn install +yarn tsc +yarn build +``` + +You should only need to do this once. + +After that, go to the `packages/backend` directory and run + +```bash +AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \ +AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \ +AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \ +AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \ +LOG_LEVEL=debug \ +yarn start +``` + +Substitute `x` for actual values, or leave them as +dummy values just to try out the backend without using the auth or sentry features. + +The backend starts up on port 7000 per default. + +## Populating The Catalog + +If you want to use the catalog functionality, you need to add so called locations +to the backend. These are places where the backend can find some entity descriptor +data to consume and serve. + +To get started, you can issue the following after starting the backend, from inside +the `plugins/catalog-backend` directory: + +```bash +yarn mock-data +``` + +You should then start seeing data on `localhost:7000/catalog/entities`. + +The catalog currently runs in-memory only, so feel free to try it out, but it will +need to be re-populated on next startup. + +## Authentication + +We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/). + +Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md) + +## Documentation + +- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) diff --git a/packages/cli/templates/default-app/packages/backend/package.json.hbs b/packages/cli/templates/default-app/packages/backend/package.json.hbs new file mode 100644 index 0000000000..93c076a967 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/package.json.hbs @@ -0,0 +1,51 @@ +{ + "name": "backend", + "version": "0.0.0", + "main": "dist/index.cjs.js", + "types": "src/index.ts", + "private": true, + "engines": { + "node": "12" + }, + "scripts": { + "build": "backstage-cli backend:build", + "build-image": "backstage-cli backend:build-image example-backend", + "start": "backstage-cli backend:dev", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "clean": "backstage-cli clean", + "migrate:create": "knex migrate:make -x ts" + }, + "dependencies": { + "@backstage/backend-common": "^{{version}}", + "@backstage/catalog-model": "^{{version}}", + "@backstage/config": "^0.1.1-alpha.13", + "@backstage/config-loader": "^0.1.1-alpha.13", + "@backstage/plugin-auth-backend": "^{{version}}", + "@backstage/plugin-catalog-backend": "^{{version}}", + "@backstage/plugin-identity-backend": "^{{version}}", + "@backstage/plugin-proxy-backend": "^{{version}}", + "@backstage/plugin-rollbar-backend": "^{{version}}", + "@backstage/plugin-scaffolder-backend": "^{{version}}", + "@backstage/plugin-sentry-backend": "^{{version}}", + "@backstage/plugin-techdocs-backend": "^{{version}}", + "@octokit/rest": "^18.0.0", + "dockerode": "^3.2.0", + "express": "^4.17.1", + "knex": "^0.21.1", + {{#if dbTypePG}} + "pg": "^8.3.0", + {{/if}} + {{#if dbTypeSqlite}} + "sqlite3": "^4.2.0", + {{/if}} + "winston": "^3.2.1" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.15", + "@types/dockerode": "^2.5.32", + "@types/express": "^4.17.6", + "@types/express-serve-static-core": "^4.17.5", + "@types/helmet": "^0.0.47" + } +} diff --git a/packages/cli/templates/default-app/packages/backend/src/index.test.ts b/packages/cli/templates/default-app/packages/backend/src/index.test.ts new file mode 100644 index 0000000000..d18873c1f0 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/index.test.ts @@ -0,0 +1,24 @@ +/* + * 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 { PluginEnvironment } from './types'; + +describe('test', () => { + it('unbreaks the test runner', () => { + const unbreaker = {} as PluginEnvironment; + expect(unbreaker).toBeTruthy(); + }); +}); diff --git a/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs b/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs new file mode 100644 index 0000000000..591fa1a28f --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs @@ -0,0 +1,111 @@ +/* + * 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. + */ + +/* + * Hi! + * + * Note that this is an EXAMPLE Backstage backend. Please check the README. + * + * Happy hacking! + */ + +import { + createServiceBuilder, + getRootLogger, + useHotMemoize, +} from '@backstage/backend-common'; +import { ConfigReader, AppConfig } from '@backstage/config'; +import { loadConfig } from '@backstage/config-loader'; +{{#if dbTypePG}} +import knex, { PgConnectionConfig } from 'knex'; +{{/if}} +{{#if dbTypeSqlite}} +import knex from 'knex'; +{{/if}} +import auth from './plugins/auth'; +import catalog from './plugins/catalog'; +import identity from './plugins/identity'; +import scaffolder from './plugins/scaffolder'; +import proxy from './plugins/proxy'; +import techdocs from './plugins/techdocs'; +import { PluginEnvironment } from './types'; + +function makeCreateEnv(loadedConfigs: AppConfig[]) { + const config = ConfigReader.fromConfigs(loadedConfigs); + + return (plugin: string): PluginEnvironment => { + const logger = getRootLogger().child({ type: 'plugin', plugin }); + + {{#if dbTypePG}} + const knexConfig = { + client: 'pg', + useNullAsDefault: true, + connection: { + port: process.env.POSTGRES_PORT, + host: process.env.POSTGRES_HOST, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + database: `backstage_plugin_${plugin}`, + } as PgConnectionConfig, + }; + {{/if}} + {{#if dbTypeSqlite}} + const knexConfig = { + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }; + {{/if}} + const database = knex(knexConfig); + database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + return { logger, database, config }; + }; +} + +async function main() { + const configs = await loadConfig(); + const configReader = ConfigReader.fromConfigs(configs); + const createEnv = makeCreateEnv(configs); + + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); + const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); + const authEnv = useHotMemoize(module, () => createEnv('auth')); + const identityEnv = useHotMemoize(module, () => createEnv('identity')); + const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); + const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + + const service = createServiceBuilder(module) + .loadConfig(configReader) + .addRouter('/catalog', await catalog(catalogEnv)) + .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) + .addRouter('/auth', await auth(authEnv)) + .addRouter('/identity', await identity(identityEnv)) + .addRouter('/techdocs', await techdocs(techdocsEnv)) + .addRouter('/proxy', await proxy(proxyEnv)); + + await service.start().catch(err => { + console.log(err); + process.exit(1); + }); +} + +module.hot?.accept(); +main().catch(error => { + console.error(`Backend failed to start up, ${error}`); + process.exit(1); +}); diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts new file mode 100644 index 0000000000..b24dd6ca6b --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts @@ -0,0 +1,26 @@ +/* + * 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 { createRouter } from '@backstage/plugin-auth-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + database, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config, database }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts new file mode 100644 index 0000000000..41e4226001 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -0,0 +1,56 @@ +/* + * 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 { + createRouter, + DatabaseEntitiesCatalog, + DatabaseLocationsCatalog, + DatabaseManager, + HigherOrderOperations, + LocationReaders, + runPeriodically, +} from '@backstage/plugin-catalog-backend'; +import { PluginEnvironment } from '../types'; +import { useHotCleanup } from '@backstage/backend-common'; + +export default async function createPlugin({ + logger, + database, +}: PluginEnvironment) { + const locationReader = new LocationReaders(logger); + + const db = await DatabaseManager.createDatabase(database, { logger }); + const entitiesCatalog = new DatabaseEntitiesCatalog(db); + const locationsCatalog = new DatabaseLocationsCatalog(db); + const higherOrderOperation = new HigherOrderOperations( + entitiesCatalog, + locationsCatalog, + locationReader, + logger, + ); + + useHotCleanup( + module, + runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000), + ); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + logger, + }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts new file mode 100644 index 0000000000..63a326965c --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts @@ -0,0 +1,22 @@ +/* + * 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 { createRouter } from '@backstage/plugin-identity-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + return await createRouter({ logger }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts new file mode 100644 index 0000000000..4964de130e --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ +// @ts-ignore +import { createRouter } from '@backstage/plugin-proxy-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts new file mode 100644 index 0000000000..a9e5f185ec --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -0,0 +1,56 @@ +/* + * 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 { + CookieCutter, + createRouter, + FilePreparer, + GithubPreparer, + Preparers, + GithubPublisher, + CreateReactAppTemplater, + Templaters, +} from '@backstage/plugin-scaffolder-backend'; +import { Octokit } from '@octokit/rest'; +import type { PluginEnvironment } from '../types'; +import Docker from 'dockerode'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + const cookiecutterTemplater = new CookieCutter(); + const craTemplater = new CreateReactAppTemplater(); + const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); + + const filePreparer = new FilePreparer(); + const githubPreparer = new GithubPreparer(); + const preparers = new Preparers(); + + preparers.register('file', filePreparer); + preparers.register('github', githubPreparer); + + const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); + const publisher = new GithubPublisher({ client: githubClient }); + + const dockerClient = new Docker(); + return await createRouter({ + preparers, + templaters, + publisher, + logger, + dockerClient, + }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts new file mode 100644 index 0000000000..0d03bfabab --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -0,0 +1,22 @@ +/* + * 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 { createRouter } from '@backstage/plugin-techdocs-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + return await createRouter({ logger }); +} diff --git a/packages/cli/templates/default-app/packages/backend/src/types.ts b/packages/cli/templates/default-app/packages/backend/src/types.ts new file mode 100644 index 0000000000..f7df3d05c6 --- /dev/null +++ b/packages/cli/templates/default-app/packages/backend/src/types.ts @@ -0,0 +1,25 @@ +/* + * 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 Knex from 'knex'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; + +export type PluginEnvironment = { + logger: Logger; + database: Knex; + config: Config; +}; diff --git a/packages/cli/templates/default-app/tsconfig.json b/packages/cli/templates/default-app/tsconfig.json index 52cdd1e825..9dbc3bbe27 100644 --- a/packages/cli/templates/default-app/tsconfig.json +++ b/packages/cli/templates/default-app/tsconfig.json @@ -1,8 +1,14 @@ { "extends": "@backstage/cli/config/tsconfig.json", - "include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"], - "exclude": ["**/node_modules"], + "include": [ + "packages/*/src", + "plugins/*/src", + "plugins/*/dev", + "plugins/*/migrations" + ], + "exclude": ["node_modules"], "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "skipLibCheck": true } } diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index da611a13b7..d5d25b974a 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -36,7 +36,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 25512529b8..4cc52c9cbf 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -30,13 +30,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", "fs-extra": "^9.0.0", "yaml": "^1.9.2", "yup": "^0.29.1" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/yup": "^0.28.2" }, diff --git a/packages/config/package.json b/packages/config/package.json index aac52ab023..f264304e85 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -33,7 +33,7 @@ "lodash": "^4.17.15" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 1faacf8062..5aca780e30 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -41,12 +41,12 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/test-utils-core": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/test-utils-core": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/zen-observable": "^0.8.0", "jest-fetch-mock": "^3.0.3" diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 63a588fc10..1fa6e70d1f 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -17,7 +17,7 @@ import { createApiRef } from '../ApiRef'; import { Config } from '@backstage/config'; // Using interface to make the ConfigApi name show up in docs -export interface ConfigApi extends Config {} +export type ConfigApi = Config; export const configApiRef = createApiRef({ id: 'core.config', diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index 9309cfd437..aaea681cef 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -90,7 +90,7 @@ class GithubAuth implements OAuthApi, SessionStateApi { const sessionManager = new StaticAuthSessionManager({ connector, - defaultScopes: new Set(['user']), + defaultScopes: new Set(['read:user']), sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core/package.json b/packages/core/package.json index 685f4066e5..3faaa33ddf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/core-api": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/core-api": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -54,14 +54,14 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/classnames": "^2.2.9", "@types/google-protobuf": "^3.7.2", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-helmet": "^5.0.15", "@types/zen-observable": "^0.8.0", diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx index 4a404a840f..62760f72fb 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -89,7 +89,7 @@ export const WithLink = () => ( message={ This is a dismissable banner with a link:{' '} - + example.com diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index eebb884806..747ed06b37 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -18,13 +18,14 @@ import React, { FC, ReactNode, useState, useEffect } from 'react'; import { useApi, storageApiRef } from '@backstage/core-api'; import { useObservable } from 'react-use'; import classNames from 'classnames'; -import { makeStyles, Theme } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; import Snackbar from '@material-ui/core/Snackbar'; import SnackbarContent from '@material-ui/core/SnackbarContent'; import IconButton from '@material-ui/core/IconButton'; import Close from '@material-ui/icons/Close'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { position: 'relative', padding: theme.spacing(0), @@ -46,10 +47,10 @@ const useStyles = makeStyles((theme: Theme) => ({ alignItems: 'center', }, info: { - backgroundColor: theme.palette.primary.main, + backgroundColor: theme.palette.banner.info, }, error: { - backgroundColor: theme.palette.error.dark, + backgroundColor: theme.palette.banner.error, }, })); diff --git a/packages/core/src/layout/Header/Header.stories.tsx b/packages/core/src/layout/Header/Header.stories.tsx index 700120906f..426229e9ed 100644 --- a/packages/core/src/layout/Header/Header.stories.tsx +++ b/packages/core/src/layout/Header/Header.stories.tsx @@ -86,7 +86,7 @@ export const App = () => ( ); export const Other = () => ( - +
{labels}
diff --git a/packages/core/src/layout/Header/Waves.tsx b/packages/core/src/layout/Header/Waves.tsx index a43ef14062..e87fc78105 100644 --- a/packages/core/src/layout/Header/Waves.tsx +++ b/packages/core/src/layout/Header/Waves.tsx @@ -37,7 +37,9 @@ type Props = { export const Waves: FC = ({ theme }) => { const classes = useStyles(); - const [color1, color2] = theme.gradient.colors; + const [backgroundColor1, backgroundColor2] = theme.gradient.colors; + const waveColor = theme.gradient.waveColor; + const [opacityStart, opacityStop] = theme.gradient.opacity; return ( = ({ theme }) => { className={classes.wave} data-testid="wave-svg" > - + = ({ theme }) => { width="1440" height="94" > - + = ({ theme }) => { + + + + = ({ theme }) => { y2="59.5001" gradientUnits="userSpaceOnUse" > - + + = ({ theme }) => { y2="241.925" gradientUnits="userSpaceOnUse" > - - + + = ({ theme }) => { y2="-103.806" gradientUnits="userSpaceOnUse" > - - + + = ({ theme }) => { y2="104.375" gradientUnits="userSpaceOnUse" > - - + + = ({ theme }) => { y2="194.894" gradientUnits="userSpaceOnUse" > - - + + diff --git a/packages/core/src/layout/Page/PageThemeProvider.ts b/packages/core/src/layout/Page/PageThemeProvider.ts index 9c31fcb4ac..36430021bb 100644 --- a/packages/core/src/layout/Page/PageThemeProvider.ts +++ b/packages/core/src/layout/Page/PageThemeProvider.ts @@ -16,6 +16,8 @@ export type Gradient = { colors: string[]; + waveColor: string; + opacity: string[]; }; export type PageTheme = { @@ -23,59 +25,45 @@ export type PageTheme = { }; export const gradients: Record = { - blue: { - colors: ['#2D46B9', '#509BF5'], + darkGrey: { + colors: ['#171717', '#383838'], + waveColor: '#757575', + opacity: ['1.0', '0.0'], }, - darkBlue: { - colors: ['#1E3264', '#A0C3D2'], + marineBlue: { + colors: ['#00759A', '#004EAC'], + waveColor: '#BDDBFF', + opacity: ['0.72', '0.0'], }, - brown: { - colors: ['#674638', '#C39887'], + veryBlue: { + colors: ['#0B2B9C', '#311288'], + waveColor: '#8960FD', + opacity: ['0.72', '0.0'], }, - green: { - colors: ['#1DB954', '#006350'], + rubyRed: { + colors: ['#A4284B', '#8D1134'], + waveColor: '#FFBFF5', + opacity: ['0.28', '0.10'], }, - orangeYellow: { - colors: ['#FF6437', '#FFC864'], + toastyOrange: { + colors: ['#CC3707', '#9A2500'], + waveColor: '#FF784E', + opacity: ['0.72', '0.0'], }, - redOrange: { - colors: ['#A72525', '#E6542D'], - }, - pinkOrange: { - colors: ['#F13DA2', '#FF8A48'], - }, - purpleBlue: { - colors: ['#2D00AA', '#C769B5'], - }, - tealGreen: { - colors: ['#19E68C', '#1D7F6E'], - }, - violetPeach: { - colors: ['#B39AC8', '#FCCBD3'], - }, - violetGreen: { - colors: ['#4302F4', '#C3EFC8'], - }, - purple: { - colors: ['#a186bd', '#7c5c92'], + purpleSky: { + colors: ['#AF29F8', '#4100F4'], + waveColor: '#AF29F8', + opacity: ['0.72', '0.0'], }, eveningSea: { colors: ['#00FFF2', '#035355'], - }, - royalBlue: { - colors: ['#000044', '#4B80D4'], - }, - grey: { - colors: ['#111111', '#777777'], - }, - sunset: { - colors: ['#cf8022', '#4e6ec7'], - }, - sky: { - colors: ['#69B9FF', '#ACCEEC'], + waveColor: '', + opacity: ['0.72', '0.0'], }, teal: { - colors: ['#005E4D', '#9BF0E1'], + colors: ['#005E4D', '#004E40'], + waveColor: '#9BF0E1', + opacity: ['0.72', '0.0'], }, }; @@ -87,21 +75,21 @@ export const pageTheme: Record = { gradient: gradients.eveningSea, }, tool: { - gradient: gradients.purpleBlue, + gradient: gradients.purpleSky, }, service: { - gradient: gradients.green, + gradient: gradients.marineBlue, }, website: { - gradient: gradients.purple, + gradient: gradients.veryBlue, }, library: { - gradient: gradients.sunset, + gradient: gradients.rubyRed, }, other: { - gradient: gradients.brown, + gradient: gradients.darkGrey, }, app: { - gradient: gradients.redOrange, + gradient: gradients.toastyOrange, }, }; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 5f66fc6558..e69d63186d 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -22,6 +22,7 @@ import { oauth2ApiRef, oktaAuthApiRef, useApi, + configApiRef, } from '@backstage/core-api'; import Collapse from '@material-ui/core/Collapse'; import SignOutIcon from '@material-ui/icons/MeetingRoom'; @@ -39,6 +40,9 @@ export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); const [open, setOpen] = React.useState(false); const identityApi = useApi(identityApiRef); + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const providers = providersConfig?.keys() ?? []; // Close the provider list when sidebar collapse useEffect(() => { @@ -49,31 +53,41 @@ export function SidebarUserSettings() { <> - - - - - + {providers.includes('google') && ( + + )} + {providers.includes('github') && ( + + )} + {providers.includes('gitlab') && ( + + )} + {providers.includes('okta') && ( + + )} + {providers.includes('oauth2') && ( + + )} { + const githubAuthApi = useApi(githubAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await githubAuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await githubAuthApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return githubAuthApi.getBackstageIdentity().then(i => i!.idToken); + }, + logout: async () => { + await githubAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Github + + + ); +}; + +const loader: ProviderLoader = async apis => { + const githubAuthApi = apis.get(githubAuthApiRef)!; + + const identity = await githubAuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await githubAuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => + githubAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await githubAuthApi.logout(); + }, + }; +}; + +export const githubProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 53d6b75eac..1eab51e3ee 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -20,6 +20,7 @@ import { googleProvider } from './googleProvider'; import { customProvider } from './customProvider'; import { gitlabProvider } from './gitlabProvider'; import { oktaProvider } from './oktaProvider'; +import { githubProvider } from './githubProvider'; import { SignInPageProps, SignInResult, @@ -32,19 +33,15 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = - | 'guest' - | 'google' - | 'gitlab' - | 'custom' - | 'okta'; +export type SignInProviderId = 'guest' | string; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, google: googleProvider, gitlab: gitlabProvider, - custom: customProvider, + oauth2: customProvider, okta: oktaProvider, + github: githubProvider, }; export const useSignInProviders = ( diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 0ffffa620e..07f74acd92 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", @@ -46,7 +46,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/packages/docgen/.eslintrc.js b/packages/docgen/.eslintrc.js new file mode 100644 index 0000000000..503c048748 --- /dev/null +++ b/packages/docgen/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + rules: { + 'no-console': 0, + }, +}; diff --git a/packages/docgen/README.md b/packages/docgen/README.md new file mode 100644 index 0000000000..be1d7cd521 --- /dev/null +++ b/packages/docgen/README.md @@ -0,0 +1,21 @@ +# DocGen - API Reference Documentation Generator + +The docgen package provides a CLI to generate markdown documentation for all exported ApiRefs in `@backstage/core`. The documentation is generated based on exported `ApiRef` instances and their type parameters. + +The CLI supports generating both TechDocs and GitHub Markdown, where the TechDocs one provides some better linking and syntax highlighting. + +## Usage + +To generate markdown documentation in the top-level `docs/` directory, run the following: + +```bash +yarn docgen +``` + +## TODO + +This package was lifted out from the Spotify internal Backstage project and could use some further work: + +- Use a higher-level TypeScript compiler library, e.g. `ts-morph`. +- Support for generating docs for any package or multiple packages. +- Better handling of self-referencing types in APIs, e.g. ConfigApi. diff --git a/packages/docgen/bin/backstage-docgen b/packages/docgen/bin/backstage-docgen new file mode 100755 index 0000000000..2b445ca9af --- /dev/null +++ b/packages/docgen/bin/backstage-docgen @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { + require('..'); +} else { + require('ts-node').register({ + transpileOnly: true, + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/docgen/package.json b/packages/docgen/package.json new file mode 100644 index 0000000000..97d057a4b7 --- /dev/null +++ b/packages/docgen/package.json @@ -0,0 +1,51 @@ +{ + "name": "docgen", + "description": "Tool for generating API Documentation for itself", + "version": "0.1.1-alpha.16", + "private": true, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/docgen" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs cjs", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "docgen": "backstage-docgen generate --output ../../docs/reference/utility-apis --format github && prettier --write ../../docs/reference/utility-apis", + "clean": "backstage-cli clean", + "start": "nodemon --" + }, + "bin": { + "backstage-docgen": "bin/backstage-docgen" + }, + "dependencies": { + "chalk": "^4.0.0", + "commander": "^4.1.1", + "fs-extra": "^9.0.0", + "github-slugger": "^1.3.0", + "ts-node": "^8.6.2", + "typescript": "^3.9.3" + }, + "devDependencies": { + "@types/fs-extra": "^9.0.1", + "@types/github-slugger": "^1.3.0", + "@types/node": "^13.7.2", + "nodemon": "^2.0.2" + }, + "files": [ + "bin", + "dist" + ], + "nodemonConfig": { + "watch": "./src", + "exec": "bin/backstage-docgen", + "ext": "ts" + } +} diff --git a/packages/docgen/src/docgen/ApiDocGenerator.test.ts b/packages/docgen/src/docgen/ApiDocGenerator.test.ts new file mode 100644 index 0000000000..947ce7ca04 --- /dev/null +++ b/packages/docgen/src/docgen/ApiDocGenerator.test.ts @@ -0,0 +1,267 @@ +/* + * 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 ts from 'typescript'; +import TypeLocator from './TypeLocator'; +import { createMemProgram } from './testUtils'; +import ApiDocGenerator from './ApiDocGenerator'; + +describe('ApiDocGenerator', () => { + it('should generate empty API doc', () => { + const program = createMemProgram( + ` + import MyApi from './type'; + + type MyApiType = {}; + + export const myApi = new MyApi({ + id: 'my-id', + description: 'my-description', + }); + `, + { + '/mem/type.ts': `export default class MyApi { + constructor(private readonly info: { id: string, description: string }) {} + }`, + }, + ); + + const typeLocator = TypeLocator.fromProgram(program, '/'); + + const { apiInstances } = typeLocator.findExportedInstances({ + apiInstances: typeLocator.getExportedType('/mem/type.ts'), + }); + + expect(apiInstances.length).toBe(1); + const [apiInstance] = apiInstances; + + const docGenerator = ApiDocGenerator.fromProgram(program, '/'); + const doc = docGenerator.toDoc(apiInstance); + + expect(doc.id).toBe('my-id'); + expect(doc.description).toBe('my-description'); + expect(doc.name).toBe('myApi'); + expect(doc.file).toBe('mem/index.ts'); + expect(doc.lineInFile).toBe(6); + expect(doc.interfaceInfos).toEqual([ + { + dependentTypes: [], + docs: [], + file: 'mem/index.ts', + lineInFile: 4, + members: [], + name: 'MyApiType', + }, + ]); + }); + + it('should generate API docs', () => { + const program = createMemProgram( + ` + import MyApi from './type'; + + /** MySubSubType Docs */ + type MySubSubType = { n: number; }; + + /** MySubType Docs */ + type MySubType = { + /** Field a docs */ + a: boolean; + // Field b docs + b: MySubSubType; + } + + /** MySecondSubType Docs */ + + /** With multiple comments */ + export type MySecondSubType = { s: string }; + + // MyThirdSubType Docs that shouldn't show up + type MyThirdSubType = { b: boolean }; + + /** MyApiType Docs */ + type MyApiType = { + /** Docs for x */ + x: string; + // Line comments shouldn't show up + y: MySubType; + /** Multiple */ + /** JsDoc */ + /** Comments */ + z(a: Promise): Array; + }; + + /** Should not show up */ + export const myApi = new MyApi({ + id: 'my-id', + description: 'my-description', + }); + `, + { + '/mem/type.ts': `export default class MyApi { + constructor(private readonly info: { id: string, description: string }) {} + }`, + }, + ); + + const source = program.getSourceFile('/mem/index.ts'); + + // Figure out type IDs so we can make sure they match later + const checker = program.getTypeChecker(); + const symbols = checker.getSymbolsInScope( + source!.getChildren().slice(-1)[0], + ts.SymbolFlags.TypeAlias, + ); + const Ids = [ + 'MySubType', + 'MySubSubType', + 'MySecondSubType', + 'MyThirdSubType', + ].reduce((ids, name) => { + const symbol = symbols.find(s => s.escapedName === name)!; + const type = checker.getTypeAtLocation(symbol.declarations[0]); + ids[name] = (type.aliasSymbol as any).id; + return ids; + }, {} as { [key in string]: number }); + + const typeLocator = TypeLocator.fromProgram(program, '/mem'); + + const { apiInstances } = typeLocator.findExportedInstances({ + apiInstances: typeLocator.getExportedType('/mem/type.ts'), + }); + + expect(apiInstances.length).toBe(1); + const [apiInstance] = apiInstances; + + const docGenerator = ApiDocGenerator.fromProgram(program, '/mem'); + const doc = docGenerator.toDoc(apiInstance); + + expect(doc.id).toBe('my-id'); + expect(doc.description).toBe('my-description'); + expect(doc.name).toBe('myApi'); + expect(doc.file).toBe('index.ts'); + expect(doc.interfaceInfos.length).toBe(1); + expect(doc.interfaceInfos[0].docs).toEqual(['MyApiType Docs']); + expect(doc.interfaceInfos[0].file).toBe('index.ts'); + expect(doc.interfaceInfos[0].lineInFile).toBe(24); + expect(doc.interfaceInfos[0].name).toBe('MyApiType'); + expect(doc.interfaceInfos[0].members).toEqual([ + { + type: 'prop', + name: 'x', + path: 'MyApiType.x', + text: 'x: string', + docs: ['Docs for x'], + links: [], + }, + { + type: 'prop', + name: 'y', + path: 'MyApiType.y', + text: 'y: MySubType', + docs: [], + links: [ + { + id: Ids.MySubType, + name: 'MySubType', + path: 'index.ts/MySubType', + location: [3, 12], + }, + ], + }, + { + type: 'method', + name: 'z', + path: 'MyApiType.z', + text: + 'z(a: Promise): Array', + docs: ['Multiple', 'JsDoc', 'Comments'], + links: [ + { + id: Ids.MySecondSubType, + name: 'MySecondSubType', + path: 'index.ts/MySecondSubType', + location: [27, 42], + }, + { + id: Ids.MyThirdSubType, + name: 'MyThirdSubType', + path: 'index.ts/MyThirdSubType', + location: [56, 70], + }, + ], + }, + ]); + expect(doc.interfaceInfos[0].dependentTypes).toEqual([ + { + id: Ids.MySubType, + name: 'MySubType', + path: 'index.ts/MySubType', + file: 'index.ts', + lineInFile: 8, + text: `type MySubType = { + /** Field a docs */ + a: boolean; + // Field b docs + b: MySubSubType; + }`, + docs: ['MySubType Docs'], + links: [ + { + id: Ids.MySubSubType, + name: 'MySubSubType', + path: 'index.ts/MySubSubType', + location: [102, 114], + }, + ], + children: [], + }, + { + id: Ids.MySubSubType, + name: 'MySubSubType', + path: 'index.ts/MySubSubType', + file: 'index.ts', + lineInFile: 5, + text: 'type MySubSubType = { n: number; }', + docs: ['MySubSubType Docs'], + links: [], + children: [], + }, + { + id: Ids.MySecondSubType, + name: 'MySecondSubType', + path: 'index.ts/MySecondSubType', + file: 'index.ts', + lineInFile: 18, + text: 'export type MySecondSubType = { s: string }', + docs: ['MySecondSubType Docs', 'With multiple comments'], + links: [], + children: [], + }, + { + id: Ids.MyThirdSubType, + name: 'MyThirdSubType', + path: 'index.ts/MyThirdSubType', + file: 'index.ts', + lineInFile: 21, + text: 'type MyThirdSubType = { b: boolean }', + docs: [], + links: [], + children: [], + }, + ]); + }); +}); diff --git a/packages/docgen/src/docgen/ApiDocGenerator.ts b/packages/docgen/src/docgen/ApiDocGenerator.ts new file mode 100644 index 0000000000..bfe449155b --- /dev/null +++ b/packages/docgen/src/docgen/ApiDocGenerator.ts @@ -0,0 +1,334 @@ +/* + * 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 ts from 'typescript'; +import { relative } from 'path'; +import { + ExportedInstance, + ApiDoc, + InterfaceInfo, + FieldInfo, + TypeInfo, + TypeLink, +} from './types'; + +/** + * The ApiDocGenerator uses the typescript compiler API to build the data structure that + * describes a Backstage API and all of it's related types. + * + * It receives an exported instance that of the form + * `export name = createApiRef({id: ..., description: ...})`. + * It will then traverse all fields and methods on the interface type, and also + * types and declaration of all types that are used by those, both directly and indirectly. + * While traversing, it collects information such as names, location in source, docs, etc. + */ +export default class ApiDocGenerator { + static fromProgram(program: ts.Program, sourcePath: string) { + return new ApiDocGenerator(program.getTypeChecker(), sourcePath); + } + + constructor( + private readonly checker: ts.TypeChecker, + private readonly basePath: string, + ) {} + + /** + * Generate documentation information for a given exported symbol. + */ + toDoc(apiInstance: ExportedInstance): ApiDoc { + const { name, source, args, typeArgs } = apiInstance; + + const [info] = args; + if (!ts.isObjectLiteralExpression(info)) { + throw new Error('api info is not an object literal'); + } + + const id = this.getObjectPropertyLiteral(info, 'id'); + const description = this.getObjectPropertyLiteral(info, 'description'); + const file = relative(this.basePath, source.fileName); + const { line } = source.getLineAndCharacterOfPosition( + apiInstance.node.getStart(), + ); + + const rootTypeNode = typeArgs[0]; + const typeNodes = ts.isIntersectionTypeNode(rootTypeNode) + ? rootTypeNode.types.slice() + : [rootTypeNode]; + const interfaceInfos = typeNodes.map(typeNode => + this.getInterfaceInfo(typeNode), + ); + + return { + id, + name, + file, + lineInFile: line + 1, + description, + interfaceInfos, + }; + } + + /** + * Grab jsDoc and regular comments for a node + */ + private getNodeDocs(node: ts.Node): string[] { + const docNodes = ((node && (node as any).jsDoc) || []) as ts.JSDoc[]; + const docs = docNodes.map(docNode => docNode.comment || '').filter(Boolean); + return docs; + } + + /** + * Collect information about a top-level type. + */ + private getInterfaceInfo(typeNode: ts.TypeNode): InterfaceInfo { + if (ts.isTypeQueryNode(typeNode)) { + throw new Error( + 'APIs must have a proper type parameter, TypeQueries are now allowed', + ); + } + if (!ts.isTypeReferenceNode(typeNode)) { + throw new Error('Interface is not a type node'); + } + + const type = this.checker.getTypeFromTypeNode(typeNode); + const interfaceMembers = type.symbol.members as Map; + if (!interfaceMembers) { + throw new Error('Interface does not have any members'); + } + const name = (type.aliasSymbol || type.symbol).name; + const [declaration] = (type.aliasSymbol || type.symbol).declarations; + const sourceFile = declaration.getSourceFile(); + const file = relative(this.basePath, sourceFile.fileName); + const { line } = sourceFile.getLineAndCharacterOfPosition( + declaration.getStart(), + ); + const docs = this.getNodeDocs(declaration); + + const membersAndTypes = Array.from( + interfaceMembers.values(), + ).flatMap(fieldSymbol => this.getMemberInfo(name, fieldSymbol)); + + const members = membersAndTypes.map(t => t.member); + const dependentTypes = this.flattenTypes( + membersAndTypes.flatMap(t => t.dependentTypes), + ); + + return { name, docs, file, lineInFile: line + 1, members, dependentTypes }; + } + + /** + * Grab primitive values from an object literal expression. + */ + private getObjectPropertyLiteral( + objectLiteral: ts.ObjectLiteralExpression, + propertyName: string, + ): string { + const matchingProp = objectLiteral.properties.filter( + prop => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === propertyName, + )[0] as ts.PropertyAssignment; + + if (!matchingProp) { + throw new Error(`no identifier found for property ${propertyName}`); + } + + const { initializer } = matchingProp; + if (!ts.isStringLiteral(initializer)) { + throw new Error(`no string literal for ${propertyName}`); + } + + return initializer.text; + } + + /** + * Get field definitions for a given symbol. + */ + private getMemberInfo( + parentName: string, + symbol: ts.Symbol, + ): { member: FieldInfo; dependentTypes: TypeInfo[] } { + const declaration = symbol.valueDeclaration; + + const { links, infos: dependentTypes } = this.findAllTypeReferences( + declaration, + ); + + let type: FieldInfo['type'] = 'prop'; + if ( + ts.isPropertySignature(declaration) && + declaration.type && + ts.isFunctionTypeNode(declaration.type) + ) { + type = 'method'; + } else if (ts.isMethodSignature(declaration)) { + type = 'method'; + } + + return { + member: { + type, + name: symbol.name, + path: `${parentName}.${symbol.name}`, + text: declaration.getText().replace(/;$/, ''), + docs: this.getNodeDocs(declaration), + links: this.recalculateLinkOffsets(declaration, links), + }, + dependentTypes, + }; + } + + /** + * Recursively search for references to types that we care about and want to include in the documentation. + */ + private findAllTypeReferences = ( + node: ts.Node | undefined, + visited: Set = new Set(), + ): { links: TypeLink[]; infos: TypeInfo[] } => { + if (!node || visited.has(node)) { + return { links: [], infos: [] }; + } + // This makes sure we don't end up in a loop. + // It doesn't exclude repeated types, e.g. Array>, since we're exiting on duplicate nodes, not types. + visited.add(node); + + const children = node + .getChildren() + .flatMap(child => this.findAllTypeReferences(child, visited)); + + if (!ts.isTypeReferenceNode(node)) { + return { + links: children.flatMap(child => child.links), + infos: children.flatMap(child => child.infos), + }; + } + + const type = this.checker.getTypeFromTypeNode(node); + + const info = this.validateTypeDeclaration(type); + if (!info) { + return { + links: children.flatMap(child => child.links), + infos: children.flatMap(child => child.infos), + }; + } + const { symbol, declaration } = info; + + const typeRefs = this.findAllTypeReferences(declaration, visited); + + const sourceFile = declaration.getSourceFile(); + const { line } = sourceFile.getLineAndCharacterOfPosition( + declaration.getStart(), + ); + const file = relative(this.basePath, sourceFile.fileName); + const typeInfo = { + id: (symbol as any).id, + name: symbol.name, + text: declaration.getText().replace(/;$/, ''), + docs: this.getNodeDocs(declaration), + path: `${file}/${symbol.name}`, + file, + lineInFile: line + 1, // TS line is 0-based + links: this.recalculateLinkOffsets(declaration, typeRefs.links), + children: typeRefs.infos, + }; + + const link = { + id: typeInfo.id, + path: typeInfo.path, + name: typeInfo.name, + location: [node.typeName.getStart(), node.typeName.getEnd()] as const, + }; + + return { + links: [link, ...children.flatMap(child => child.links)], + infos: [typeInfo, ...children.flatMap(child => child.infos)], + }; + }; + + /** + * Check if a given type declaration is one that we care about. + */ + private validateTypeDeclaration( + type: ts.Type | undefined, + ): undefined | { symbol: ts.Symbol; declaration: ts.Declaration } { + if (!type) { + return undefined; + } + const symbol = type.aliasSymbol || type.symbol; + if (!symbol) { + return undefined; + } + + const [declaration] = symbol.declarations; + + // Don't generate standalone infos for type paramters + if (ts.isTypeParameterDeclaration(declaration)) { + return undefined; + } + + if (declaration.getSourceFile().fileName.includes('node_modules')) { + return undefined; + } + + return { symbol, declaration }; + } + + /** + * Flatten a tree of TypeInfos with children into a flat + * list of TypeInfos with duplicates removed. + */ + private flattenTypes(descs: TypeInfo[]): TypeInfo[] { + function getChildren(parent: TypeInfo): TypeInfo[] { + return [ + { + ...parent, + children: [], + }, + ...parent.children.flatMap(getChildren), + ]; + } + + const flatDescs = descs.flatMap(getChildren); + const seenTypes = new Set(); + return flatDescs.filter(desc => { + if (seenTypes.has(desc.id)) { + return false; + } + seenTypes.add(desc.id); + return true; + }); + } + + /** + * Calculate link positions within a block of code, with 0 being + * the first character in the block. + */ + private recalculateLinkOffsets( + parent: ts.Node, + links: TypeLink[], + ): TypeLink[] { + const parentStart = parent.getStart(); + return links.map(link => { + const [start, end] = link.location; + return { + ...link, + location: [start - parentStart, end - parentStart], + }; + }); + } +} diff --git a/packages/docgen/src/docgen/ApiDocsPrinter.ts b/packages/docgen/src/docgen/ApiDocsPrinter.ts new file mode 100644 index 0000000000..fdd53afc8e --- /dev/null +++ b/packages/docgen/src/docgen/ApiDocsPrinter.ts @@ -0,0 +1,163 @@ +/* + * 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 sortSelector from './sortSelector'; +import { ApiDoc, InterfaceInfo, MarkdownPrinter } from './types'; + +/** + * The ApiDocPrinter takes a ApiDoc data structure, typically generated by an ApiDocGenerator, + * and prints it out as a markdown doc with custom code highlighting and links. + */ +export default class ApiDocPrinter { + printerFactory: () => MarkdownPrinter; + + constructor(printerFactory: () => MarkdownPrinter) { + this.printerFactory = printerFactory; + } + + /** + * Print an index file with all ApiRefs and what types they implement. + */ + printApiIndex(apiDocs: ApiDoc[]): Buffer { + const printer = this.printerFactory(); + + printer.header(1, 'Backstage Core Utility APIs'); + + printer.paragraph( + 'The following is a list of all Utility APIs defined by `@backstage/core`.', + 'They are available to use by plugins and components, and can be accessed ', + 'using the `useApi` hook, also provided by `@backstage/core`.', + 'For more information, see https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md.', + ); + + for (const api of apiDocs) { + printer.header(3, `${this.apiDisplayName(api)}`, api.id); + + printer.paragraph(api.description); + + const typeLinks = api.interfaceInfos.map( + i => `[${i.name}](${printer.pageLink(i.name)})`, + ); + printer.paragraph( + `Implemented type${typeLinks.length > 1 ? 's' : ''}: ${typeLinks.join( + ', ', + )}`, + ); + + printer.paragraph(`ApiRef: ${printer.srcLink(api, api.name)}`); + } + + return printer.toBuffer(); + } + + /** + * Print documentation page for a type implemented by an ApiRef and + */ + printInterface(apiType: InterfaceInfo, apiDocs: ApiDoc[]): Buffer { + const printer = this.printerFactory(); + + printer.header(1, apiType.name); + + printer.paragraph( + `The ${apiType.name} type is defined at ${printer.srcLink(apiType)}.`, + ); + + const apiLinks = apiDocs + .filter(ad => ad.interfaceInfos.some(i => i.name === apiType.name)) + .map(ad => { + const link = + printer.indexLink() + + printer.headerLink(this.apiDisplayName(ad), ad.id); + return `[${ad.name}](${link})`; + }); + + if (apiLinks.length === 1) { + printer.paragraph( + `The following Utility API implements this type: ${apiLinks}`, + ); + } else { + printer.paragraph(`The following Utility APIs implement this type:`); + for (const link of apiLinks) { + printer.text(` - ${link}`); + } + } + + printer.header(2, 'Members'); + + this.addInterfaceMembers(printer, apiType); + + if (apiType.dependentTypes.length) { + printer.header(2, 'Supporting types'); + printer.paragraph( + 'These types are part of the API declaration, but may not be unique to this API.', + ); + + this.addInterfaceTypes(printer, apiType); + } + + return printer.toBuffer(); + } + + private addInterfaceMembers( + printer: MarkdownPrinter, + apiType: InterfaceInfo, + ) { + for (const member of apiType.members) { + printer.header( + 3, + `${member.name}${member.type === 'method' ? '()' : ''}`, + member.path, + ); + + for (const doc of member.docs) { + printer.text(doc); + } + + printer.code(member); + } + } + + private addInterfaceTypes(printer: MarkdownPrinter, apiType: InterfaceInfo) { + for (const type of apiType.dependentTypes + .slice() + .sort(sortSelector(x => x.name))) { + printer.header(3, `${type.name}`, type.path); + + for (const doc of type.docs) { + printer.text(doc); + } + printer.code(type); + + printer.paragraph(`Defined at ${printer.srcLink(type)}.`); + + const usageLinks = [...apiType.members, ...apiType.dependentTypes] + .filter(member => { + return member.links.some(link => link.id === type.id); + }) + .map( + ({ name, path }) => `[${name}](${printer.headerLink(name, path)})`, + ); + + if (usageLinks.length) { + printer.paragraph(`Referenced by: ${usageLinks.join(', ')}.`); + } + } + } + + private apiDisplayName(api: ApiDoc): string { + return api.name.replace(/ApiRef$/, ''); + } +} diff --git a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts new file mode 100644 index 0000000000..64d38d8b15 --- /dev/null +++ b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts @@ -0,0 +1,145 @@ +/* + * 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 GithubSlugger from 'github-slugger'; +import sortSelector from './sortSelector'; +import { MarkdownPrinter, TypeLink } from './types'; +import { execSync } from 'child_process'; + +// TODO(Rugvip): provide through options? +const GH_BASE_URL = 'https://github.com/spotify/backstage'; + +const COMMIT_SHA = + process.env.COMMIT_SHA || + execSync('git rev-parse HEAD').toString('utf8').trim(); + +/** + * The GithubMarkdownPrinter is a MarkdownPrinter for printing Github-flavored markdown documents. + */ +export default class GithubMarkdownPrinter implements MarkdownPrinter { + private str: string = ''; + + private line(line: string = '') { + this.str += `${line}\n`; + } + + text(text: string) { + this.line(text); + this.line(); + } + + header(level: number, text: string) { + this.line(`${'#'.repeat(level)} ${text}`); + this.line(); + } + + headerLink(heading: string): string { + const slug = GithubSlugger.slug(heading); + return `#${slug}`; + } + + indexLink() { + return './README.md'; + } + + pageLink(name: string) { + return `./${name}.md`; + } + + srcLink( + { file, lineInFile }: { file: string; lineInFile: number }, + text?: string, + ): string { + const linkText = text ?? `${file}:${lineInFile}`; + const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`; + return `[${linkText}](${href})`; + } + + paragraph(...text: string[]) { + this.line( + text + .join('\n') + .trim() + .split('\n') + .map(line => line.trim()) + .join('\n'), + ); + this.line(); + } + + code({ text, links = [] }: { text: string; links?: TypeLink[] }) { + this.line('
');
+    this.line(this.formatWithLinks({ text, links }));
+    this.line('
'); + this.line(); + } + + private escapeText: (text: string) => string = (() => { + const escapes: { [char in string]: string } = { + '&': '&', + '<': '<', + '>': '>', + }; + + return (text: string) => text.replace(/[&<>]/g, char => escapes[char]); + })(); + + private formatWithLinks({ + text, + links = [], + }: { + text: string; + links?: TypeLink[]; + }): string { + const sortedLinks = links.slice().sort(sortSelector(x => x.location[0])); + + sortedLinks.reduce((lastEnd, link) => { + if (link.location[0] <= lastEnd) { + throw new Error( + `overlapping link detected for ${link.path}, ${link.location}`, + ); + } + return link.location[1]; + }, -1); + + const parts: Array<{ text: string; link?: boolean }> = []; + + const endLocation = sortedLinks.reduce((prev, link) => { + const [start, end] = link.location; + parts.push( + { text: text.slice(prev, start) }, + { text: text.slice(start, end), link: true }, + ); + return end; + }, 0); + + parts.push({ text: text.slice(endLocation) }); + + return parts + .map(part => { + if (part.link) { + const link = this.headerLink(part.text); + return `${this.escapeText(part.text)}`; + } + return this.escapeText(part.text); + }) + .join(''); + } + + toBuffer(): Buffer { + return Buffer.from(this.str, 'utf8'); + } +} diff --git a/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts new file mode 100644 index 0000000000..f2453f314f --- /dev/null +++ b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts @@ -0,0 +1,161 @@ +/* + * 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 sortSelector from './sortSelector'; +import { Highlighter, MarkdownPrinter, TypeLink } from './types'; +import { execSync } from 'child_process'; + +// TODO(Rugvip): provide through options? +const GH_BASE_URL = 'https://github.com/spotify/backstage'; + +const COMMIT_SHA = + process.env.COMMIT_SHA || execSync('git rev-parse HEAD').toString('utf8'); + +/** + * The TechdocsMarkdownPrinter is a MarkdownPrinter for building TechDocs markdown documents. + */ +export default class TechdocsMarkdownPrinter implements MarkdownPrinter { + private str: string = ''; + private readonly highlighter: Highlighter; + + constructor(highlighter: Highlighter) { + this.highlighter = highlighter; + + // Remove line numbers from codeblocks + this.style('.linenodiv{ display: none }'); + } + + private line(line: string = '') { + this.str += `${line}\n`; + } + + text(text: string) { + this.line(text); + this.line(); + } + + style(style: string) { + this.line(''); + } + + header(level: number, text: string, id?: string) { + this.line(`${'#'.repeat(level)} ${text}${id ? ` {#${id}}` : ''}`); + this.line(); + } + + headerLink(heading: string, id?: string): string { + return `#${id ?? heading}`; + } + + indexLink() { + return '../'; + } + + pageLink(name: string) { + return `./${name}/`; + } + + srcLink( + { file, lineInFile }: { file: string; lineInFile: number }, + text?: string, + ): string { + const linkText = text ?? `${file}:${lineInFile}`; + const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`; + return `[${linkText}](${href})`; + } + + paragraph(...text: string[]) { + this.line( + text + .join('\n') + .trim() + .split('\n') + .map(line => line.trim()) + .join('\n'), + ); + this.line(); + } + + code({ text, links = [] }: { text: string; links?: TypeLink[] }) { + this.line('
'); + this.line( + '
', + ); + this.line('
');
+    this.line(this.formatWithLinks({ text, links }));
+    this.line('
'); + this.line('
'); + this.line('
'); + } + + private escapeText: (text: string) => string = (() => { + const escapes: { [char in string]: string } = { + '&': '&', + '<': '<', + '>': '>', + }; + + return (text: string) => text.replace(/[&<>]/g, char => escapes[char]); + })(); + + private formatWithLinks({ + text, + links = [], + }: { + text: string; + links?: TypeLink[]; + }): string { + const sortedLinks = links.slice().sort(sortSelector(x => x.location[0])); + + sortedLinks.reduce((lastEnd, link) => { + if (link.location[0] <= lastEnd) { + throw new Error( + `overlapping link detected for ${link.path}, ${link.location}`, + ); + } + return link.location[1]; + }, -1); + + const parts: Array<{ text: string; path?: string }> = []; + + const endLocation = sortedLinks.reduce((prev, link) => { + const [start, end] = link.location; + parts.push( + { text: text.slice(prev, start) }, + { text: text.slice(start, end), path: link.path }, + ); + return end; + }, 0); + + parts.push({ text: text.slice(endLocation) }); + + return parts + .map(part => { + if (part.path) { + const link = this.headerLink(part.text, part.path); + return `${this.escapeText(part.text)}`; + } + return this.highlighter.highlight(this.escapeText(part.text)); + }) + .join(''); + } + + toBuffer(): Buffer { + return Buffer.from(this.str, 'utf8'); + } +} diff --git a/packages/docgen/src/docgen/TypeLocator.test.ts b/packages/docgen/src/docgen/TypeLocator.test.ts new file mode 100644 index 0000000000..5a264fd41c --- /dev/null +++ b/packages/docgen/src/docgen/TypeLocator.test.ts @@ -0,0 +1,68 @@ +/* + * 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 ts from 'typescript'; +import TypeLocator from './TypeLocator'; +import { createMemProgram } from './testUtils'; + +describe('TypeLocator', () => { + it('should find a default export', () => { + const program = createMemProgram('export default class MyApi {}'); + + const typeLocator = TypeLocator.fromProgram(program, '/mem'); + + const apiType = typeLocator.getExportedType('/mem/index.ts'); + expect(apiType.symbol.name).toBe('default'); + const [declaration] = apiType.symbol.declarations; + expect(declaration.kind).toBe(ts.SyntaxKind.ClassDeclaration); + expect((declaration as ts.ClassDeclaration).name).toBeDefined(); + expect((declaration as ts.ClassDeclaration).name!.text).toBe('MyApi'); + }, 10000); + + it('should find api instance export', () => { + const program = createMemProgram( + ` + import MyApi from './type'; + + type MyApiType = {}; + + export const myApi = new MyApi(); + `, + { + '/mem/type.ts': 'export default class MyApi {}', + }, + ); + + const typeLocator = TypeLocator.fromProgram(program, '/mem'); + + const { apiInstances } = typeLocator.findExportedInstances({ + apiInstances: typeLocator.getExportedType('/mem/type.ts'), + }); + + expect(apiInstances.length).toBe(1); + const [apiInstance] = apiInstances; + expect(apiInstance.name).toBe('myApi'); + expect(apiInstance.source.fileName).toBe('/mem/index.ts'); + expect(apiInstance.args).toEqual([]); + + expect(apiInstance.typeArgs.length).toBe(1); + const [typeArg] = apiInstance.typeArgs; + expect(typeArg.kind).toBe(ts.SyntaxKind.TypeReference); + expect((typeArg as ts.TypeReferenceNode).typeName.getText()).toBe( + 'MyApiType', + ); + }); +}); diff --git a/packages/docgen/src/docgen/TypeLocator.ts b/packages/docgen/src/docgen/TypeLocator.ts new file mode 100644 index 0000000000..6030258112 --- /dev/null +++ b/packages/docgen/src/docgen/TypeLocator.ts @@ -0,0 +1,142 @@ +/* + * 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 ts from 'typescript'; +import { ExportedInstance } from './types'; + +/** + * The TypeLocator is used to extract typescrint Type structures from a compiled program, + * as well as finding locations where types are used in according to a matching pattern. + * + * This is used to e.g. find exported APIs that we should generate documentation for. + */ +export default class TypeLocator { + static fromProgram(program: ts.Program, sourcePath: string) { + return new TypeLocator(program.getTypeChecker(), program, sourcePath); + } + + constructor( + private readonly checker: ts.TypeChecker, + private readonly program: ts.Program, + private readonly sourcePath: string, + ) {} + + /** + * Get the type of a symbol by export path and name + */ + getExportedType(path: string, exportedName: string = 'default'): ts.Type { + const source = this.program.getSourceFile(path); + if (!source) { + throw new Error(`Source not found for path '${path}'`); + } + const exported = this.checker.getExportsOfModule( + this.checker.getSymbolAtLocation(source)!, + ); + const [symbol] = exported.filter(e => e.name === exportedName); + if (!symbol) { + throw new Error(`No export '${exportedName}' found in ${path}`); + } + const type = this.checker.getTypeOfSymbolAtLocation(symbol, source); + return type; + } + + /** + * Find exported instances and return values from calls using the types + * provided in the lookup table. + */ + findExportedInstances( + typeLookupTable: { [key in T]: ts.Type }, + ): { [key in T]: ExportedInstance[] } { + const docMap = new Map(); + for (const type of Object.values(typeLookupTable)) { + docMap.set(type, []); + } + + this.program.getSourceFiles().forEach(source => { + const inRoot = source.fileName.startsWith(this.sourcePath); + if (!inRoot) { + return; + } + ts.forEachChild(source, node => { + const decl = this.getExportedConstructorDeclaration(node); + if (!decl || !docMap.has(decl.constructorType)) { + return; + } + + docMap.get(decl.constructorType)!.push({ + node, + name: decl.name, + source, + args: Array.from(decl.initializer.arguments || []), + typeArgs: Array.from(decl.initializer.typeArguments || []), + }); + }); + }); + + const result: { [key in T]?: ExportedInstance[] } = {}; + + for (const key in typeLookupTable) { + if (typeLookupTable.hasOwnProperty(key)) { + const type = typeLookupTable[key]; + result[key] = docMap.get(type)!; + } + } + + return result as { [key in T]: ExportedInstance[] }; + } + + private getExportedConstructorDeclaration( + node: ts.Node, + ): + | { + constructorType: ts.Type; + initializer: ts.CallExpression | ts.NewExpression; + name: string; + } + | undefined { + if (!ts.isVariableStatement(node)) { + return undefined; + } + if ( + !node.modifiers || + !node.modifiers.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) + ) { + return undefined; + } + const { declarations } = node.declarationList; + if (declarations.length !== 1) { + return undefined; + } + + const [declaration] = declarations; + const { initializer, name } = declaration; + + if (!initializer || !name) { + return undefined; + } + if (!ts.isCallOrNewExpression(initializer)) { + return undefined; + } + if (!ts.isIdentifier(name)) { + return undefined; + } + + const constructorType = this.checker.getTypeAtLocation( + initializer.expression, + ); + return { constructorType, initializer, name: name.text }; + } +} diff --git a/packages/docgen/src/docgen/TypescriptHighlighter.ts b/packages/docgen/src/docgen/TypescriptHighlighter.ts new file mode 100644 index 0000000000..ffdd4f1906 --- /dev/null +++ b/packages/docgen/src/docgen/TypescriptHighlighter.ts @@ -0,0 +1,87 @@ +/* + * 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 { Highlighter } from './types'; + +// Simple syntax highlighter that mimics hilite +export default class TypescriptHighlighter implements Highlighter { + private static readonly basicTypes = [ + 'boolean', + 'number', + 'string', + 'Array', + 'object', + 'Record', + 'Set', + 'Map', + 'true', + 'false', + 'null', + 'undefined', + 'void', + 'Promise', + 'any', + '[0-9\\.]+', + ]; + + // List of highlightings to apply, each with a match regex and the style that should be applied + private static readonly highlighters = [ + [/(\/\*\*?(?:.|\n)+?\*\/)/g, 'color: #60a0b0; font-style: italic'], // block comment + [/(\/\/.*)/gm, 'color: #60a0b0; font-style: italic'], // line comment + [/('[^']*?'|"[^"]*?")/g, 'color: #4070a0'], // string literals + [ + new RegExp( + `\\b(${TypescriptHighlighter.basicTypes.join('|')})\\b(?!:|,)`, + 'g', + ), + 'color: #902000', + ], // basic types + [ + /^((?:export\s)?(?:type\s)?(?:interface\s)?)/g, + 'color: #007020; font-weight: bold', + ], // keywords + ] as readonly [RegExp, string][]; + + highlight(fullText: string): string { + // Each part is either plain text that can be highlighted or text that is already highlighted + type HighlightPart = { text: string; highlighted?: boolean }; + + const painter = (regex: RegExp, style: string) => (part: HighlightPart) => { + if (part.highlighted) { + return [part]; + } + // Apply highlighting to all matches of the regex by splitting into parts + return part.text.split(regex).map((text, index) => { + // Odd parts are the ones that matched the regex and should be highlighted. + if (index % 2 === 1) { + return { + text: `${text}`, + highlighted: true, + }; + } + return { text }; + }); + }; + + // Order here is important, e.g. comments must be first to avoid string literals inside comments being highlighted + return TypescriptHighlighter.highlighters + .reduce((parts, highlighter) => parts.flatMap(painter(...highlighter)), [ + { text: fullText }, + ]) + .map(({ text }) => text) + .join(''); + } +} diff --git a/packages/docgen/src/docgen/sortSelector.test.ts b/packages/docgen/src/docgen/sortSelector.test.ts new file mode 100644 index 0000000000..a3da3e2950 --- /dev/null +++ b/packages/docgen/src/docgen/sortSelector.test.ts @@ -0,0 +1,49 @@ +/* + * 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 sortSelector from './sortSelector'; + +describe('sortSelector', () => { + it('should stable sort', () => { + const arr = [ + [3, 1], + [1, 2], + [1, 1], + [1, 3], + [2, 1], + ]; + + const sortedByFirst = arr.slice().sort(sortSelector(([first]) => first)); + expect(sortedByFirst).toEqual([ + [1, 2], + [1, 1], + [1, 3], + [2, 1], + [3, 1], + ]); + + const sortedBySecond = arr + .slice() + .sort(sortSelector(([, second]) => second)); + expect(sortedBySecond).toEqual([ + [3, 1], + [1, 1], + [2, 1], + [1, 2], + [1, 3], + ]); + }); +}); diff --git a/packages/docgen/src/docgen/sortSelector.ts b/packages/docgen/src/docgen/sortSelector.ts new file mode 100644 index 0000000000..7590ae39ec --- /dev/null +++ b/packages/docgen/src/docgen/sortSelector.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/** + * The sortSelector is a utility that makes a sort function by selecting the value to sort on with a lambda. + */ +export default function sortSelector( + selector: (x: T) => any, +): (a: T, b: T) => -1 | 1 | 0 { + return (a: T, b: T) => { + const aV = selector(a); + const bV = selector(b); + if (aV < bV) { + return -1; + } else if (aV > bV) { + return 1; + } + return 0; + }; +} diff --git a/packages/docgen/src/docgen/testUtils.ts b/packages/docgen/src/docgen/testUtils.ts new file mode 100644 index 0000000000..552776016a --- /dev/null +++ b/packages/docgen/src/docgen/testUtils.ts @@ -0,0 +1,67 @@ +/* + * 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 ts from 'typescript'; + +export function createMemProgram( + indexSource: string, + otherSourceFiles: { [fileName in string]: string } = {}, +): ts.Program { + const rootDir = '/mem'; + + const options = { noEmit: true }; + const baseHost = ts.createCompilerHost(options); + const files: { [fileName in string]: string } = { + [`${rootDir}/index.ts`]: indexSource, + ...otherSourceFiles, + }; + + // Custom compiler hosts that reads from a map of in-memory files, but + // falls back to reading from disc for ts libs etc. + const compilerHost: ts.CompilerHost = { + ...baseHost, + readFile(fileName): string | undefined { + if (fileName in files) { + return files[fileName]; + } + return baseHost.readFile(fileName); + }, + getCurrentDirectory(): string { + return rootDir; + }, + directoryExists(dir) { + if (dir === rootDir) { + return true; + } + if (baseHost.directoryExists) { + return baseHost.directoryExists(dir); + } + return false; + }, + fileExists(fileName: string): boolean { + return !!files[fileName] || baseHost.fileExists(fileName); + }, + getSourceFile(fileName, ...rest): ts.SourceFile | undefined { + const file = files[fileName]; + if (file) { + return ts.createSourceFile(fileName, file, ts.ScriptTarget.ES2017); + } + return baseHost.getSourceFile(fileName, ...rest); + }, + }; + + return ts.createProgram(['/mem/index.ts'], options, compilerHost); +} diff --git a/packages/docgen/src/docgen/types.ts b/packages/docgen/src/docgen/types.ts new file mode 100644 index 0000000000..7aff54d04c --- /dev/null +++ b/packages/docgen/src/docgen/types.ts @@ -0,0 +1,119 @@ +/* + * 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 ts from 'typescript'; + +/** + * A TypeLink is a link to a different type. + */ +export type TypeLink = { + // The ID of the linked type + id: number; + // The path to the type from the project root + path: string; + // The name of the type, to display + name: string; + // The location of the type name as it appears in it's parent text. + location: readonly [number, number]; +}; + +/** + * TypeInfo describes a Typescript Type. + */ +export type TypeInfo = { + id: number; + name: string; + path: string; + file: string; + lineInFile: number; + text: string; + docs: string[]; + links: TypeLink[]; + children: TypeInfo[]; +}; + +/** + * FieldInfo describes a property or method in a documented API. + */ +export type FieldInfo = { + type: 'prop' | 'method'; + name: string; + path: string; + text: string; + docs: string[]; + links: TypeLink[]; +}; + +/** + * InterfaceInfo describes the type of a documented API. + */ +export type InterfaceInfo = { + name: string; + docs: string[]; + file: string; + lineInFile: number; + members: Array; + dependentTypes: TypeInfo[]; +}; + +/** + * ApiDoc describes a documented API. + */ +export type ApiDoc = { + id: string; + name: string; + description: string; + file: string; + lineInFile: number; + interfaceInfos: InterfaceInfo[]; +}; + +/** + * ExportedInstance describes an expression matching `export {name} = new {Contructor}<{typeArgs}...>({args}...)` + */ +export type ExportedInstance = { + node: ts.Node; + name: string; + source: ts.SourceFile; + args: Array; + typeArgs: Array; +}; + +export type Highlighter = { + highlight(test: string): string; +}; + +/** + * Markdown printer is an abstraction for printing markdown documents of different flavors. + */ +export type MarkdownPrinter = { + text(text: string): void; + header(level: number, text: string, id?: string): void; + paragraph(...text: string[]): void; + code(options: { text: string; links?: TypeLink[] }): void; + + headerLink(header: string, id?: string): string; + /** Link from pages to index */ + indexLink(): string; + /** Link from index to pages */ + pageLink(name: string): string; + srcLink( + { file, lineInFile }: { file: string; lineInFile: number }, + text?: string, + ): string; + + toBuffer(): Buffer; +}; diff --git a/packages/docgen/src/generate.ts b/packages/docgen/src/generate.ts new file mode 100644 index 0000000000..3587e2776b --- /dev/null +++ b/packages/docgen/src/generate.ts @@ -0,0 +1,126 @@ +/* + * 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 * as ts from 'typescript'; +import fs from 'fs-extra'; +import { resolve as resolvePath, join as joinPath } from 'path'; +import ApiDocGenerator from './docgen/ApiDocGenerator'; +import sortSelector from './docgen/sortSelector'; +import TypeLocator from './docgen/TypeLocator'; +import ApiDocPrinter from './docgen/ApiDocsPrinter'; +import TypescriptHighlighter from './docgen/TypescriptHighlighter'; +import GitHubMarkdownPrinter from './docgen/GitHubMarkdownPrinter'; +import TechdocsMarkdownPrinter from './docgen/TechdocsMarkdownPrinter'; + +const FORMATS = ['github', 'techdocs'] as const; + +export async function generate( + targetPath: string, + format: typeof FORMATS[number], +) { + if (!FORMATS.includes(format)) { + throw new TypeError( + `Invalid format, '${format}', must be one of ${FORMATS.join(', ')}`, + ); + } + + const rootDir = resolvePath(__dirname, '../../..'); + const srcDir = resolvePath(rootDir, 'packages', 'core-api', 'src'); + const targetDir = resolvePath(targetPath); + + const options = await fs.readJson(resolvePath('../cli/config/tsconfig.json')); + + delete options.moduleResolution; + options.noEmit = true; + + const program = ts.createProgram([resolvePath(srcDir, 'index.ts')], options); + + const typeLocator = TypeLocator.fromProgram(program, srcDir); + + const { apis } = typeLocator.findExportedInstances({ + apis: typeLocator.getExportedType( + resolvePath(srcDir, 'index.ts'), + 'createApiRef', + ), + }); + + const apiDocGenerator = ApiDocGenerator.fromProgram(program, rootDir); + const apiDocs = apis + .map(api => { + try { + return apiDocGenerator.toDoc(api); + } catch (error) { + throw new Error( + `Doc generation failed for API in ${api.source.fileName}, ${error.stack}`, + ); + } + }) + .sort(sortSelector(x => x.name)); + + const apiTypes = Object.values( + Object.fromEntries( + apiDocs.flatMap(d => d.interfaceInfos).map(i => [i.name, i]), + ), + ).sort(sortSelector(i => i.name)); + + if (format === 'techdocs') { + const docsDir = resolvePath(targetDir, 'docs'); + await fs.ensureDir(docsDir); + + const apiDocPrinter = new ApiDocPrinter( + () => new TechdocsMarkdownPrinter(new TypescriptHighlighter()), + ); + + await fs.writeFile( + joinPath(docsDir, 'README.md'), + apiDocPrinter.printApiIndex(apiDocs), + ); + + for (const apiType of Object.values(apiTypes)) { + const data = apiDocPrinter.printInterface(apiType, apiDocs); + + await fs.writeFile(joinPath(docsDir, `${apiType.name}.md`), data); + } + + await fs.writeFile( + resolvePath(targetDir, 'mkdocs.yml'), + [ + 'site_name: Backstage Core Utility API References', + 'nav:', + ` - API Index: 'README.md'`, + ...apiTypes.map(({ name }) => ` - ${name}: '${name}.md'`), + 'plugins:', + ' - techdocs-core', + ].join('\n'), + 'utf8', + ); + } else { + await fs.ensureDir(targetDir); + + const apiDocPrinter = new ApiDocPrinter(() => new GitHubMarkdownPrinter()); + + await fs.writeFile( + joinPath(targetDir, 'README.md'), + apiDocPrinter.printApiIndex(apiDocs), + ); + + for (const apiType of Object.values(apiTypes)) { + const data = apiDocPrinter.printInterface(apiType, apiDocs); + + await fs.writeFile(joinPath(targetDir, `${apiType.name}.md`), data); + } + } +} diff --git a/packages/docgen/src/index.ts b/packages/docgen/src/index.ts new file mode 100644 index 0000000000..866299151a --- /dev/null +++ b/packages/docgen/src/index.ts @@ -0,0 +1,63 @@ +/* + * 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 program from 'commander'; +import { resolve as resolvePath } from 'path'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import { generate } from './generate'; + +const main = (argv: string[]) => { + const pkgJson = fs.readJsonSync(resolvePath(__dirname, '../package.json')); + program.name('docgen').version(pkgJson.version); + + program + .command('generate') + .description( + 'Generate documentation for the declarations in the core-api package', + ) + .option('--output ', 'Output directory [./dist]') + .option( + '--format ', + 'Output format, either techdocs or github [techdocs]', + ) + .action(async cmd => { + await generate(cmd.output ?? './dist', cmd.format ?? 'techdocs'); + }); + + program.on('command:*', () => { + console.log(); + console.log( + chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`), + ); + console.log(chalk.red('See --help for a list of available commands.')); + console.log(); + process.exit(1); + }); + + if (!process.argv.slice(2).length) { + program.outputHelp(chalk.yellow); + } + + program.parse(argv); +}; + +process.on('unhandledRejection', rejection => { + console.error(String(rejection)); + process.exit(1); +}); + +main(process.argv); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index f4b7557b89..051d95de05 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -18,10 +18,14 @@ import { OAuthRequestManager, OktaAuth, oktaAuthApiRef, + configApiRef, + ConfigReader, } from '@backstage/core'; const builder = ApiRegistry.builder(); +builder.add(configApiRef, ConfigReader.fromConfigs([])); + const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); diff --git a/packages/storybook/package.json b/packages/storybook/package.json index db34ee02a7..dafd592bb0 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "description": "Storybook build for core package", "private": true, "scripts": { @@ -14,7 +14,7 @@ ] }, "dependencies": { - "@backstage/theme": "^0.1.1-alpha.13" + "@backstage/theme": "^0.1.1-alpha.16" }, "devDependencies": { "@storybook/addon-actions": "^5.3.17", diff --git a/packages/techdocs-cli/README.md b/packages/techdocs-cli/README.md index 960574db37..5d8a3869c7 100644 --- a/packages/techdocs-cli/README.md +++ b/packages/techdocs-cli/README.md @@ -4,44 +4,45 @@ Check out the [TechDocs README](https://github.com/spotify/backstage/blob/master **WIP: This cli is a work in progress. It is not ready for use yet. Follow our progress on [the Backstage Discord](https://discord.gg/MUpMjP2) under #docs-like-code or on [our GitHub Milestone](https://github.com/spotify/backstage/milestone/15).** -## Commands +## Prerequisities + +Run the following command from the project root: ```bash -# Serve localhost:3000 (and localhost:8000) -yarn serve - -# Serve localhost:8000 containing your Mkdocs documentation. -yarn serve:mkdocs +yarn install ``` -## Getting Started +## Run TechDocs CLI -You'll need Docker installed and running to use this. You will also need to build the container located at `/packages/techdocs-container` under the tag `mkdocs:local-dev`, as you can see in the commands from below: - -```bash -docker build packages/techdocs-container -t mkdocs:local-dev -``` - -From that point, you can invoke the CLI from any project with a docs folder. Try out our example! +You'll need Docker installed and running to use this. ```bash cd packages/techdocs-container/mock-docs -npx @techdocs/cli serve + +# To get a view of your docs in Backstage, use: +npx techdocs-cli serve + +# To view the raw mkdocs site (without Backstage), use: +npx techdocs-cli serve:mkdocs ``` -## Local Development +If you run `npx techdocs-cli serve` you should have a `localhost:3000` serving TechDocs in Backstage, as well as `localhost:8000` serving Mkdocs (which won't open up and be exposed to the user). -You'll need Docker installed and running to use this. You will also need to build the container located at `packages/techdocs-container` under the tag `mkdocs:local-dev` (for now until we deploy the container to a centralized Docker registry), as you can see in the commands from below: - -```bash -docker build packages/techdocs-container -t mkdocs:local-dev -``` - -```bash -cd packages/techdocs-container/mock-docs -npx techdocs serve -``` - -You should have a `localhost:3000` serving TechDocs in Backstage, as well as `localhost:8000` serving Mkdocs (which won't open up and be exposed to the user). +If running `npx techdocs-cli serve:mkdocs` you will have `localhost:8000` exposed, serving Mkdocs. Happy hacking! + +## Deploying a new version + +Deploying the Node packages to NPM happens automatically on merge to `master` through GitHub Actions. The deployment happens through Lerna which determines which packages throughout the Backstage project have changed. In our case, the package is called `techdocs-cli` in the repository but `@techdocs/cli` in the NPM registry. + +> Note: Once a package is published under a version, any subsequent changes will not override that version. You will need to bump up the version across the entire Backstage repository, which can be done through Lerna (see the command below). + +In order to bump up all packages, go to the root of the Backstage repository. To see the current version see the `lerna.json` under the `version` key. To then update all the versions (locally on your machine), run the following: + +```bash +git checkout -b bump-up-version +yarn lerna version --no-push --allow-branch --yes +``` + +Upon being merged to master, Lerna will then automatically publish these packages as configured by the Backstage core team. diff --git a/packages/techdocs-cli/bin/build b/packages/techdocs-cli/bin/build.sh similarity index 92% rename from packages/techdocs-cli/bin/build rename to packages/techdocs-cli/bin/build.sh index a86954ace9..225cc334aa 100755 --- a/packages/techdocs-cli/bin/build +++ b/packages/techdocs-cli/bin/build.sh @@ -1,3 +1,5 @@ +#!/bin/bash + # Copyright 2020 Spotify AB # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -19,7 +21,7 @@ TECHDOCS_PREVIEW_SOURCE=$ROOT_DIR/plugins/techdocs/dist TECHDOCS_PREVIEW_DEST=$ROOT_DIR/packages/techdocs-cli/dist/techdocs-preview-bundle # Build the CLI -backstage-cli build --outputs cjs +yarn run backstage-cli -- build --outputs cjs # Create export of the TechDocs plugin APP_CONFIG_techdocs_storageUrl='"http://localhost:3000/api"' yarn workspace @backstage/plugin-techdocs export @@ -27,9 +29,6 @@ APP_CONFIG_techdocs_storageUrl='"http://localhost:3000/api"' yarn workspace @bac # Copy over export to techdocs-cli dist/ folder cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST -# Clean output -yarn workspace @backstage/plugin-techdocs clean - # Write to console echo "[techdocs-cli]: Built the dist/ folder" echo "[techdocs-cli]: Imported @backstage/plugin-techdocs dist/ folder into techdocs-preview-bundle/" diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli index 8581750373..9d77a767ca 100755 --- a/packages/techdocs-cli/bin/techdocs-cli +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -21,7 +21,7 @@ const path = require('path'); const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); if (!isLocal) { - require('..'); + require('../dist'); } else { require('ts-node').register({ transpileOnly: true, diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 2e06c4a71e..a097284f82 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "CLI for running TechDocs locally.", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public" @@ -18,14 +18,14 @@ "license": "Apache-2.0", "main": "dist/index.cjs.js", "scripts": { - "build": "./bin/build", + "build": "./bin/build.sh", "lint": "backstage-cli lint", "test": "backstage-cli test --passWithNoTests", "clean": "backstage-cli clean", "start": "nodemon --" }, "bin": { - "techdocs": "bin/techdocs-cli" + "techdocs-cli": "bin/techdocs-cli" }, "devDependencies": { "@spotify/eslint-config": "^7.0.0", @@ -36,7 +36,7 @@ }, "files": [ "bin", - "dist/**/*.js" + "dist" ], "nodemonConfig": { "watch": "./src", @@ -44,8 +44,7 @@ "ext": "ts" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "chalk": "^4.1.0", + "@backstage/cli": "^0.1.1-alpha.16", "commander": "^5.1.0", "fs-extra": "^9.0.1", "http-proxy": "^1.18.1", diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts index 5b4f947a73..1469089d52 100644 --- a/packages/techdocs-cli/src/index.ts +++ b/packages/techdocs-cli/src/index.ts @@ -16,7 +16,6 @@ import { spawn, ChildProcess } from 'child_process'; import program from 'commander'; import { version } from './lib/version'; -// import chalk from 'chalk'; import path from 'path'; import HTTPServer from './lib/httpServer'; import openBrowser from 'react-dev-utils/openBrowser'; @@ -69,7 +68,7 @@ const runMkdocsServer = (options?: { '$(pwd):/content', '-p', '8000:8000', - 'mkdocs:local-dev', + 'spotify/techdocs', 'serve', '-a', devAddr, diff --git a/packages/techdocs-container/Dockerfile b/packages/techdocs-container/Dockerfile index f2a92287e4..cfda62c51d 100644 --- a/packages/techdocs-container/Dockerfile +++ b/packages/techdocs-container/Dockerfile @@ -15,10 +15,6 @@ FROM python:3.7.7-alpine3.12 RUN apk update && apk --no-cache add gcc musl-dev -RUN pip install --upgrade pip && pip install mkdocs==1.1.2 mkdocs-material==5.3.2 mkdocs-monorepo-plugin==0.4.5 pymdown-extensions==7.1 - -ADD ./techdocs-core /techdocs-core -RUN pip install --no-index /techdocs-core +RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.2 mkdocs==1.1.2 mkdocs-material==5.3.2 mkdocs-monorepo-plugin==0.4.5 pymdown-extensions==7.1 ENTRYPOINT [ "mkdocs" ] - diff --git a/packages/techdocs-container/README.md b/packages/techdocs-container/README.md index 57bdefe894..2bfa9a1d26 100644 --- a/packages/techdocs-container/README.md +++ b/packages/techdocs-container/README.md @@ -9,7 +9,7 @@ This is the Docker container that powers the creation of static documentation si Using the TechDocs CLI, we can invoke the latest version of `techdocs-container` via Docker Hub: ```bash -npx @techdocs/cli serve +npx techdocs-cli serve ``` ## Local Development diff --git a/packages/techdocs-container/techdocs-core/README.md b/packages/techdocs-container/techdocs-core/README.md index 12b3acb1e2..9608780bd2 100644 --- a/packages/techdocs-container/techdocs-core/README.md +++ b/packages/techdocs-container/techdocs-core/README.md @@ -2,9 +2,13 @@ This is the base [Mkdocs](https://mkdocs.org) plugin used when using Mkdocs with Spotify's TechDocs. It is written in Python and packages all of our Mkdocs defaults, such as theming, plugins, etc in a single plugin. +[Python Package](https://pypi.org/project/mkdocs-techdocs-core/) + ## Usage -**Installation instructions TBD.** We haven't published it to a Python registry yet. +```bash +$ pip install mkdocs-techdocs-core +``` Once you have installed the `mkdocs-techdocs-core` plugin, you'll need to add it to your `mkdocs.yml`. diff --git a/packages/techdocs-container/techdocs-core/setup.py b/packages/techdocs-container/techdocs-core/setup.py index a259ae0615..9429409319 100644 --- a/packages/techdocs-container/techdocs-core/setup.py +++ b/packages/techdocs-container/techdocs-core/setup.py @@ -18,13 +18,13 @@ from setuptools import setup, find_packages setup( name='mkdocs-techdocs-core', - version='0.0.1', + version='0.0.2', description='A Mkdocs package that contains TechDocs defaults', long_description='', keywords='mkdocs', url='https://github.com/spotify/backstage', - author='Spotify', - author_email='fossboard@spotify.com', + author='TechDocs Core', + author_email='pulp-fiction@spotify.com', license='Apache-2.0', python_requires='>=3.7', install_requires=[ diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index 10e5b0ea6b..1824a56916 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -36,7 +36,7 @@ "react-dom": "^16.12.0" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7bb5017fb0..95845b6159 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/core-api": "^0.1.1-alpha.13", - "@backstage/test-utils-core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/core-api": "^0.1.1-alpha.16", + "@backstage/test-utils-core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/packages/theme/package.json b/packages/theme/package.json index c6611750e1..144712cfe6 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.9.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13" + "@backstage/cli": "^0.1.1-alpha.16" }, "files": [ "dist" diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index c6bff73ef4..e5ebd419a3 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -41,6 +41,10 @@ export const lightTheme = createTheme({ primary: { main: blue[500], }, + banner: { + info: '#2E77D0', + error: '#E22134', + }, border: '#E6E6E6', textContrast: '#000000', textVerySubtle: '#DDD', @@ -90,6 +94,10 @@ export const darkTheme = createTheme({ primary: { main: blue[500], }, + banner: { + info: '#2E77D0', + error: '#E22134', + }, border: '#E6E6E6', textContrast: '#FFFFFF', textVerySubtle: '#DDD', diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index fdb9e09869..557af23cd0 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -58,6 +58,10 @@ type PaletteAdditions = { icon: string; background: string; }; + banner: { + info: string; + error: string; + }; }; export type BackstagePalette = Palette & PaletteAdditions; diff --git a/plugins/README.md b/plugins/README.md index 7fe6a7312d..50f4395bd9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,11 +4,11 @@ Backstage is a single-page application composed of a set of plugins. Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/design.md) we ensure the the overall user experience stays consistent between plugins. -![plugin](../docs/getting-started/my-plugin_screenshot.png) +![plugin](../docs/plugins/my-plugin_screenshot.png) ## Creating a plugin -To create a plugin, follow the steps outlined [here](https://github.com/spotify/backstage/blob/master/docs/getting-started/create-a-plugin.md). +To create a plugin, follow the steps outlined [here](https://github.com/spotify/backstage/blob/master/docs/plugins/create-a-plugin.md). ## Suggesting a plugin diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 45024ff4c0..b16c1e5a4f 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/config-loader": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/config-loader": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "body-parser": "^1.19.0", "compression": "^1.7.4", @@ -49,7 +49,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts index 6c16b3a712..3eab4e793c 100644 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts @@ -16,49 +16,56 @@ import express from 'express'; import { AuthProviderRouteHandlers } from '../providers/types'; -import { NotFoundError } from '@backstage/backend-common'; export type EnvironmentHandlers = { [key: string]: AuthProviderRouteHandlers; }; export class EnvironmentHandler implements AuthProviderRouteHandlers { - constructor(private readonly providers: EnvironmentHandlers) {} + constructor( + private readonly providerId: string, + private readonly providers: EnvironmentHandlers, + ) {} - private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers { + private getProviderForEnv( + req: express.Request, + res: express.Response, + ): AuthProviderRouteHandlers | undefined { const env = req.query.env?.toString(); - if (!this.providers.hasOwnProperty(env)) { - throw new NotFoundError( - `No environment for ${env} found in this provider`, - ); + + if (env && this.providers.hasOwnProperty(env)) { + return this.providers[env]; } - return this.providers[env]; + + res.status(404).send( + `Missing configuration. +
+
+For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`, + ); + return undefined; } async start(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req); - await provider.start(req, res); + const provider = this.getProviderForEnv(req, res); + await provider?.start(req, res); } async frameHandler( req: express.Request, res: express.Response, ): Promise { - const provider = this.getProviderForEnv(req); - await provider.frameHandler(req, res); + const provider = this.getProviderForEnv(req, res); + await provider?.frameHandler(req, res); } async refresh(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req); - if (provider.refresh) { - await provider.refresh(req, res); - } + const provider = this.getProviderForEnv(req, res); + await provider?.refresh?.(req, res); } async logout(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req); - if (provider.logout) { - await provider.logout(req, res); - } + const provider = this.getProviderForEnv(req, res); + await provider?.logout?.(req, res); } } diff --git a/plugins/auth-backend/src/lib/OAuthProvider.test.ts b/plugins/auth-backend/src/lib/OAuthProvider.test.ts index f67a91dfcd..3d631bd4ac 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.test.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.test.ts @@ -51,7 +51,7 @@ describe('OAuthProvider Utils', () => { } as unknown) as express.Request; expect(() => { verifyNonce(mockRequest, 'providera'); - }).toThrowError('Missing nonce'); + }).toThrowError('Auth response is missing cookie nonce'); }); it('should throw error if state nonce missing', () => { @@ -63,7 +63,7 @@ describe('OAuthProvider Utils', () => { } as unknown) as express.Request; expect(() => { verifyNonce(mockRequest, 'providera'); - }).toThrowError('Missing nonce'); + }).toThrowError('Auth response is missing state nonce'); }); it('should throw error if nonce mismatch', () => { diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts index 5434b6a4ba..c4996c31a2 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.ts @@ -43,10 +43,12 @@ export const verifyNonce = (req: express.Request, providerId: string) => { const cookieNonce = req.cookies[`${providerId}-nonce`]; const stateNonce = req.query.state; - if (!cookieNonce || !stateNonce) { - throw new Error('Missing nonce'); + if (!cookieNonce) { + throw new Error('Auth response is missing cookie nonce'); + } + if (!stateNonce) { + throw new Error('Auth response is missing state nonce'); } - if (cookieNonce !== stateNonce) { throw new Error('Invalid nonce'); } @@ -151,9 +153,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers { } if (!this.options.disableRefresh) { - // throw error if missing refresh token if (!refreshToken) { - throw new Error('Missing refresh token'); + throw new InputError('Missing refresh token'); } // set new refresh token diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 4c9caf214b..dcce98a986 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -24,6 +24,11 @@ import { createOAuth2Provider } from './oauth2'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { AuthProviderConfig, AuthProviderFactory } from './types'; +import { Config } from '@backstage/config'; +import { + EnvironmentHandlers, + EnvironmentHandler, +} from '../lib/EnvironmentHandler'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -37,7 +42,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { export const createAuthProviderRouter = ( providerId: string, globalConfig: AuthProviderConfig, - providerConfig: any, // TODO: make this a config reader object of sorts + providerConfig: Config, logger: Logger, issuer: TokenIssuer, ) => { @@ -46,17 +51,28 @@ export const createAuthProviderRouter = ( throw Error(`No auth provider available for '${providerId}'`); } - const provider = factory(globalConfig, providerConfig, logger, issuer); - const router = Router(); - router.get('/start', provider.start.bind(provider)); - router.get('/handler/frame', provider.frameHandler.bind(provider)); - router.post('/handler/frame', provider.frameHandler.bind(provider)); - if (provider.logout) { - router.post('/logout', provider.logout.bind(provider)); + const envs = providerConfig.keys(); + const envProviders: EnvironmentHandlers = {}; + + for (const env of envs) { + const envConfig = providerConfig.getConfig(env); + const provider = factory(globalConfig, env, envConfig, logger, issuer); + if (provider) { + envProviders[env] = provider; + } } - if (provider.refresh) { - router.get('/refresh', provider.refresh.bind(provider)); + + const handler = new EnvironmentHandler(providerId, envProviders); + + router.get('/start', handler.start.bind(handler)); + router.get('/handler/frame', handler.frameHandler.bind(handler)); + router.post('/handler/frame', handler.frameHandler.bind(handler)); + if (handler.logout) { + router.post('/logout', handler.logout.bind(handler)); + } + if (handler.refresh) { + router.get('/refresh', handler.refresh.bind(handler)); } return router; diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts new file mode 100644 index 0000000000..61d4bb887f --- /dev/null +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -0,0 +1,207 @@ +/* + * 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 { GithubAuthProvider } from './provider'; + +describe('GithubAuthProvider', () => { + describe('should transform to type OAuthResponse', () => { + it('when all fields are present, it should be able to map them', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: 'Jimmy Markum', + emails: [ + { + value: 'jimmymarkum@gmail.com', + }, + ], + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + email: 'jimmymarkum@gmail.com', + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "email" is missing, it should be able to create the profile without it', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: 'Jimmy Markum', + emails: null, + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: null, + emails: null, + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + displayName: 'jimmymarkum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "photos" is missing, it should be able to create the profile without it', () => { + const accessToken = + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe'; + const rawProfile = { + id: 'ipd12039', + username: 'daveboyle', + provider: 'gitlab', + displayName: 'Dave Boyle', + emails: [ + { + value: 'daveboyle@gitlab.org', + }, + ], + }; + + const params = { + scope: 'read:user', + }; + + const expected = { + backstageIdentity: { + id: 'daveboyle', + }, + providerInfo: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + scope: 'read:user', + expiresInSeconds: undefined, + idToken: undefined, + }, + profile: { + displayName: 'Dave Boyle', + email: 'daveboyle@gitlab.org', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index ec444733ba..37d661f9ab 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -25,23 +25,68 @@ import { OAuthProviderHandlers, AuthProviderConfig, RedirectInfo, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import passport from 'passport'; +import { Config } from '@backstage/config'; export class GithubAuthProvider implements OAuthProviderHandlers { private readonly _strategy: GithubStrategy; + static transformPassportProfile(rawProfile: any): passport.Profile { + const profile: passport.Profile = { + id: rawProfile.username, + username: rawProfile.username, + provider: rawProfile.provider, + displayName: rawProfile.displayName || rawProfile.username, + photos: rawProfile.photos, + emails: rawProfile.emails, + }; + + return profile; + } + + static transformOAuthResponse( + accessToken: string, + rawProfile: any, + params: any = {}, + ): OAuthResponse { + const passportProfile = GithubAuthProvider.transformPassportProfile( + rawProfile, + ); + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + idToken: params.id_token, + }; + + // Github provides an id numeric value (123) + // as a fallback + const id = passportProfile!.id; + + if (params.expires_in) { + providerInfo.expiresInSeconds = params.expires_in; + } + if (params.id_token) { + providerInfo.idToken = params.id_token; + } + return { + providerInfo, + profile, + backstageIdentity: { + id, + }, + }; + } + constructor(options: OAuthProviderOptions) { this._strategy = new GithubStrategy( { ...options }, @@ -52,15 +97,12 @@ export class GithubAuthProvider implements OAuthProviderHandlers { rawProfile: any, done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile); - done(undefined, { - providerInfo: { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, - }); + const oauthResponse = GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ); + done(undefined, oauthResponse); }, ); } @@ -84,44 +126,43 @@ export class GithubAuthProvider implements OAuthProviderHandlers { export function createGithubProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const providerId = 'github'; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const callbackURLParam = `?env=${env}`; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/github/handler/frame${callbackURLParam}`, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { - disableRefresh: true, - persistScopes: true, - providerId: 'github', - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(envProviders); + return new OAuthProvider(new GithubAuthProvider(opts), { + disableRefresh: true, + persistScopes: true, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 950c414750..c5c546c6f3 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -25,20 +25,15 @@ import { OAuthProviderHandlers, AuthProviderConfig, RedirectInfo, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; import passport from 'passport'; +import { Config } from '@backstage/config'; export class GitlabAuthProvider implements OAuthProviderHandlers { private readonly _strategy: GitlabStrategy; @@ -138,50 +133,45 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { export function createGitlabProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const providerId = 'gitlab'; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const baseURL = audience || 'https://gitlab.com'; + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const { - secure, - appOrigin, - clientId, - clientSecret, - audience, - } = (envConfig as unknown) as OAuthProviderConfig; - const callbackURLParam = `?env=${env}`; + const opts = { + clientID, + clientSecret, + callbackURL, + baseURL, + }; - const opts = { - clientID: clientId, - clientSecret: clientSecret, - callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`, - baseURL: audience, - }; - - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), { - disableRefresh: true, - providerId: 'gitlab', - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(envProviders); + return new OAuthProvider(new GitlabAuthProvider(opts), { + disableRefresh: true, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 04705b66c1..5749d1465f 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -27,20 +27,15 @@ import { OAuthProviderHandlers, RedirectInfo, AuthProviderConfig, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; import passport from 'passport'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -150,43 +145,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { export function createGoogleProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const providerId = 'google'; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const callbackURLParam = `?env=${env}`; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/google/handler/frame${callbackURLParam}`, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), { - disableRefresh: false, - providerId: 'google', - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(envProviders); + return new OAuthProvider(new GoogleAuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index 1b5ee6d17e..967fd011f0 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -19,10 +19,6 @@ import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { OAuthProvider } from '../../lib/OAuthProvider'; import { executeFetchUserProfileStrategy, @@ -33,14 +29,13 @@ import { } from '../../lib/PassportStrategyHelper'; import { AuthProviderConfig, - EnvironmentProviderConfig, - GenericOAuth2ProviderConfig, GenericOAuth2ProviderOptions, OAuthProviderHandlers, OAuthResponse, PassportDoneCallback, RedirectInfo, } from '../types'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -148,51 +143,51 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { export function createOAuth2Provider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const providerId = 'oauth2'; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; + const authorizationURL = envConfig.getString('authorizationURL'); + const tokenURL = envConfig.getString('tokenURL'); - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as GenericOAuth2ProviderConfig; - const { secure, appOrigin } = config; - const callbackURLParam = `?env=${env}`; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`, - authorizationURL: config.authorizationURL, - tokenURL: config.tokenURL, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + authorizationURL, + tokenURL, + }; - if ( - !opts.clientID || - !opts.clientSecret || - !opts.authorizationURL || - !opts.tokenURL - ) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', - ); - } - - logger.warn( - 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + if ( + !opts.clientID || + !opts.clientSecret || + !opts.authorizationURL || + !opts.tokenURL + ) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), { - disableRefresh: false, - providerId: 'oauth2', - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + ); + return undefined; } - - return new EnvironmentHandler(envProviders); + return new OAuthProvider(new OAuth2AuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index e73843bb0c..56e8b1220d 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -28,36 +28,30 @@ import { OAuthProviderHandlers, RedirectInfo, AuthProviderConfig, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { StateStore } from 'passport-oauth2'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; }; export class OktaAuthProvider implements OAuthProviderHandlers { - private readonly _strategy: any; - /** + /** * Due to passport-okta-oauth forcing options.state = true, * passport-oauth2 requires express-session to be installed * so that the 'state' parameter of the oauth2 flow can be stored. * This implementation of StateStore matches the NullStore found within * passport-oauth2, which is the StateStore implementation used when options.state = false, * allowing us to avoid using express-session in order to integrate with Okta. - */ + */ private _store: StateStore = { store(_req: express.Request, cb: any) { cb(null, null); @@ -65,25 +59,27 @@ export class OktaAuthProvider implements OAuthProviderHandlers { verify(_req: express.Request, _state: string, cb: any) { cb(null, true); }, - } + }; constructor(options: OAuthProviderOptions) { - this._strategy = new OktaStrategy({ - passReqToCallback: false as true, - ...options, - store: this._store, - response_type: 'code', - }, ( - accessToken: any, - refreshToken: any, - params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, - ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); + this._strategy = new OktaStrategy( + { + passReqToCallback: false as true, + ...options, + store: this._store, + response_type: 'code', + }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); - done( - undefined, + done( + undefined, { providerInfo: { idToken: params.id_token, @@ -96,13 +92,14 @@ export class OktaAuthProvider implements OAuthProviderHandlers { { refreshToken, }, - ) - }); + ); + }, + ); } async start( req: express.Request, - options: Record + options: Record, ): Promise { const providerOptions = { ...options, @@ -113,7 +110,7 @@ export class OktaAuthProvider implements OAuthProviderHandlers { } async handler( - req: express.Request + req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { const { response, privateInfo } = await executeFrameHandlerStrategy< OAuthResponse, @@ -148,7 +145,6 @@ export class OktaAuthProvider implements OAuthProviderHandlers { }, profile, }); - } private async populateIdentity( @@ -169,45 +165,44 @@ export class OktaAuthProvider implements OAuthProviderHandlers { export function createOktaProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const providerId = 'okta'; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const callbackURLParam = `?env=${env}`; - const opts = { - audience: config.audience, - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/okta/handler/frame${callbackURLParam}`, - }; + const opts = { + audience, + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret || !opts.audience) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', - ); - } - - logger.warn( - 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + if (!opts.clientID || !opts.clientSecret || !opts.audience) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), { - disableRefresh: false, - providerId: 'okta', - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + ); + return undefined; } - - return new EnvironmentHandler(envProviders); + return new OAuthProvider(new OktaAuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 68b84c7e33..3ddfba4bb0 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -27,18 +27,13 @@ import { import { AuthProviderConfig, AuthProviderRouteHandlers, - EnvironmentProviderConfig, - SAMLProviderConfig, PassportDoneCallback, ProfileInfo, } from '../types'; import { postMessageResponse } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type SamlInfo = { userId: string; @@ -122,30 +117,25 @@ type SAMLProviderOptions = { export function createSamlProvider( _authProviderConfig: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + _env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const entryPoint = envConfig.getString('entryPoint'); + const issuer = envConfig.getString('issuer'); + const opts = { + entryPoint, + issuer, + path: '/auth/saml/handler/frame', + tokenIssuer, + }; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as SAMLProviderConfig; - const opts = { - entryPoint: config.entryPoint, - issuer: config.issuer, - path: '/auth/saml/handler/frame', - tokenIssuer, - }; - - if (!opts.entryPoint || !opts.issuer) { - logger.warn( - 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable', - ); - continue; - } - - envProviders[env] = new SamlAuthProvider(opts); + if (!opts.entryPoint || !opts.issuer) { + logger.warn( + 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable', + ); + return undefined; } - - return new EnvironmentHandler(envProviders); + return new SamlAuthProvider(opts); } diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index de4e076919..93d459bb0d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -17,6 +17,9 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; +import { Config } from '@backstage/config'; +import { OAuthProvider } from '../lib/OAuthProvider'; +import { SamlAuthProvider } from './saml/provider'; export type OAuthProviderOptions = { /** @@ -204,10 +207,11 @@ export interface AuthProviderRouteHandlers { export type AuthProviderFactory = ( globalConfig: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, issuer: TokenIssuer, -) => AuthProviderRouteHandlers; +) => OAuthProvider | SamlAuthProvider | undefined; export type AuthResponse = { providerInfo: ProviderInfo; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 6b296cba2d..69f058f6db 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -36,8 +36,6 @@ export async function createRouter( const router = Router(); const logger = options.logger.child({ plugin: 'auth' }); - const appUrl = new URL(options.config.getString('app.baseUrl')); - const appOrigin = appUrl.origin; const backendUrl = options.config.getString('backend.baseUrl'); const authUrl = `${backendUrl}/auth`; @@ -57,71 +55,13 @@ export async function createRouter( router.use(bodyParser.urlencoded({ extended: false })); router.use(bodyParser.json()); - const config = { - backend: { - baseUrl: backendUrl, - }, - auth: { - providers: { - google: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GOOGLE_CLIENT_ID!, - clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!, - }, - }, - github: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GITHUB_CLIENT_ID!, - clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!, - }, - }, - gitlab: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GITLAB_CLIENT_ID!, - clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!, - audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com', - }, - }, - saml: { - development: { - entryPoint: 'http://localhost:7001/', - issuer: 'passport-saml', - }, - }, - okta: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_OKTA_CLIENT_ID!, - clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, - audience: process.env.AUTH_OKTA_AUDIENCE, - }, - }, - oauth2: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_OAUTH2_CLIENT_ID!, - clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!, - authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!, - tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!, - }, - }, - }, - }, - }; + const providersConfig = options.config.getConfig('auth.providers'); + const providers = providersConfig.keys(); - const providerConfigs = config.auth.providers; - - for (const [providerId, providerConfig] of Object.entries(providerConfigs)) { + for (const providerId of providers) { logger.info(`Configuring provider, ${providerId}`); try { + const providerConfig = providersConfig.getConfig(providerId); const providerRouter = createAuthProviderRouter( providerId, { baseUrl: authUrl }, diff --git a/plugins/catalog-backend/migrations/20200721115244_location_update_log_latest_deduplicate.js b/plugins/catalog-backend/migrations/20200721115244_location_update_log_latest_deduplicate.js new file mode 100644 index 0000000000..9046f85da0 --- /dev/null +++ b/plugins/catalog-backend/migrations/20200721115244_location_update_log_latest_deduplicate.js @@ -0,0 +1,35 @@ +/* + * 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. + */ +exports.up = function up(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; +`); +}; + +exports.down = function down(knex) { + knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 48d0aa2ef1..7b62e5579e 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "mock-data": "./scripts/mock-data.sh" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", - "@backstage/catalog-model": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", + "@backstage/catalog-model": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -39,13 +39,14 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/lodash": "^4.14.151", "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", + "msw": "^0.19.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/scripts/mock-data.sh b/plugins/catalog-backend/scripts/mock-data.sh index 76f97ccf3a..58e2efbe21 100755 --- a/plugins/catalog-backend/scripts/mock-data.sh +++ b/plugins/catalog-backend/scripts/mock-data.sh @@ -17,3 +17,11 @@ for URL in \ --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}" echo done + +curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/benjdlambert/cookiecutter-golang/blob/master/template.yaml\"}" +echo + diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 3d1455a886..21dde4ca74 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -88,6 +88,31 @@ describe('CommonDatabase', () => { expect(locations).toEqual([output]); const location = await db.location(locations[0].id); expect(location).toEqual(output); + + // If we add 2 new update log events, + // this should not result in location duplication + // due to incorrect join in DB + await db.addLocationUpdateLogEvent( + 'dd12620d-0436-422f-93bd-929aa0788123', + DatabaseLocationUpdateLogStatus.SUCCESS, + ); + + // Have a second in-between + // To avoid having same timestamp on event + await new Promise(res => setTimeout(res, 1000)); + await db.addLocationUpdateLogEvent( + 'dd12620d-0436-422f-93bd-929aa0788123', + DatabaseLocationUpdateLogStatus.FAIL, + ); + + expect(await db.locations()).toEqual([ + { + ...output, + status: DatabaseLocationUpdateLogStatus.FAIL, + timestamp: expect.any(String), + }, + ]); + await db.transaction(tx => db.removeLocation(tx, locations[0].id)); await expect(db.locations()).resolves.toEqual([]); diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 8ec575d6ec..e5ef410456 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -27,7 +27,9 @@ import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor'; import { FileReaderProcessor } from './processors/FileReaderProcessor'; import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; import { GithubApiReaderProcessor } from './processors/GithubApiReaderProcessor'; +import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; +import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; import * as result from './processors/results'; import { @@ -59,7 +61,9 @@ export class LocationReaders implements LocationReader { new FileReaderProcessor(), new GithubReaderProcessor(), new GithubApiReaderProcessor(), + new GitlabApiReaderProcessor(), new GitlabReaderProcessor(), + new UrlReaderProcessor(), new YamlProcessor(), new EntityPolicyProcessor(entityPolicy), new LocationRefProcessor(), diff --git a/plugins/catalog-backend/src/ingestion/index.ts b/plugins/catalog-backend/src/ingestion/index.ts index 07e917b97b..4ebb2edc79 100644 --- a/plugins/catalog-backend/src/ingestion/index.ts +++ b/plugins/catalog-backend/src/ingestion/index.ts @@ -24,3 +24,4 @@ export type { ReadLocationError, ReadLocationResult, } from './types'; +export * from './processors'; diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts new file mode 100644 index 0000000000..a429a0e6fb --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; + +describe('GitlabApiReaderProcessor', () => { + it('should build raw api', () => { + const processor = new GitlabApiReaderProcessor(); + + const tests = [ + { + target: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + url: new URL( + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + url: new URL( + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + url: new URL( + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/', + url: null, + err: + 'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: Gitlab url does not end in .ya?ml', + }, + ]; + + for (const test of tests) { + if (test.err) { + expect(() => processor.buildRawUrl(test.target, 12345)).toThrowError( + test.err, + ); + } else { + expect(processor.buildRawUrl(test.target, 12345)).toEqual(test.url); + } + } + }); + + it('should return request options', () => { + const tests = [ + { + token: '0123456789', + expect: { + headers: { + 'PRIVATE-TOKEN': '0123456789', + }, + }, + }, + { + token: '', + expect: { + headers: { + 'PRIVATE-TOKEN': '', + }, + }, + }, + ]; + + for (const test of tests) { + process.env.GITLAB_PRIVATE_TOKEN = test.token; + const processor = new GitlabApiReaderProcessor(); + expect(processor.getRequestOptions()).toEqual(test.expect); + } + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts new file mode 100644 index 0000000000..6964640eb4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -0,0 +1,133 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; + +export class GitlabApiReaderProcessor implements LocationProcessor { + private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || ''; + + getRequestOptions(): RequestInit { + const headers: HeadersInit = { 'PRIVATE-TOKEN': '' }; + if (this.privateToken !== '') { + headers['PRIVATE-TOKEN'] = this.privateToken; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'gitlab/api') { + return false; + } + + try { + const projectID = await this.getProjectID(location.target); + const url = this.buildRawUrl(location.target, projectID); + const response = await fetch(url.toString(), this.getRequestOptions()); + if (response.ok) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + return true; + } + + // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch + buildRawUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndfilePath = url.pathname.split('/-/blob/')[1]; + + if (!branchAndfilePath.match(/\.ya?ml$/)) { + throw new Error('Gitlab url does not end in .ya?ml'); + } + + const [branch, ...filePath] = branchAndfilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(filePath.join('/')), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } + + async getProjectID(target: string): Promise { + const url = new URL(target); + + if ( + // absPaths to gitlab files should contain /-/blob + // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + !url.pathname.match(/\/\-\/blob\//) + ) { + throw new Error('Please provide full path to yaml file from Gitlab'); + } + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Find ProjectID from url + // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' + // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + this.getRequestOptions(), + ); + const projectIDJson = await response.json(); + const projectID: Number = projectIDJson.id; + + return projectID; + } catch (e) { + throw new Error(`Could not get Gitlab ProjectID for: ${target}, ${e}`); + } + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts new file mode 100644 index 0000000000..e30b320bf4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { UrlReaderProcessor } from './UrlReaderProcessor'; +import { + LocationProcessorDataResult, + LocationProcessorResult, + LocationProcessorErrorResult, +} from './types'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +describe('UrlReaderProcessor', () => { + const mockApiOrigin = 'http://localhost:23000'; + const server = setupServer(); + + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it('should load from url', async () => { + const processor = new UrlReaderProcessor(); + const spec = { + type: 'url', + target: `${mockApiOrigin}/component.yaml`, + }; + + server.use( + rest.get(`${mockApiOrigin}/component.yaml`, (_, res, ctx) => + res(ctx.body('Hello')), + ), + ); + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as LocationProcessorDataResult; + + expect(generated.type).toBe('data'); + expect(generated.location).toBe(spec); + expect(generated.data.toString('utf8')).toBe('Hello'); + }); + + it('should fail load from url with error', async () => { + const processor = new UrlReaderProcessor(); + const spec = { + type: 'url', + target: `${mockApiOrigin}/component-notfound.yaml`, + }; + + server.use( + rest.get(`${mockApiOrigin}/component-notfound.yaml`, (_, res, ctx) => { + return res(ctx.status(404)); + }), + ); + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as LocationProcessorErrorResult; + + expect(generated.type).toBe('error'); + expect(generated.location).toBe(spec); + expect(generated.error.name).toBe('NotFoundError'); + expect(generated.error.message).toBe( + `${mockApiOrigin}/component-notfound.yaml could not be read, 404 Not Found`, + ); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts new file mode 100644 index 0000000000..0c879ea70c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -0,0 +1,55 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import fetch from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; + +export class UrlReaderProcessor implements LocationProcessor { + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'url') { + return false; + } + + try { + const response = await fetch(location.target); + + if (response.ok) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts new file mode 100644 index 0000000000..ed5dc3bf8e --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * as results from './results'; + +export { results }; +export * from './types'; diff --git a/plugins/catalog-backend/src/setupTests.ts b/plugins/catalog-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/catalog-backend/src/setupTests.ts +++ b/plugins/catalog-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 849a66295b..f306c03ff8 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.13", - "@backstage/plugin-sentry": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.16", + "@backstage/plugin-sentry": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -38,19 +38,19 @@ "swr": "^0.2.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", "msw": "^0.19.0", "react-test-renderer": "^16.13.1", - "whatwg-fetch": "^3.0.0" + "whatwg-fetch": "^2.0.0" }, "files": [ "dist" diff --git a/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx index c7a280d106..677b1f7ec5 100644 --- a/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx +++ b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx @@ -42,7 +42,7 @@ export const WelcomeBanner = () => { 🎉 Welcome to Backstage! Take a look around and check out our{' '} - + getting started {' '} page. diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 69c74da25e..3cf357bbce 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,12 +36,12 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-lazylog": "^4.5.0", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 57ca519d5d..6840cf5e52 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,13 +32,13 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index b0b339a1b0..24bc96937f 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -1,13 +1,76 @@ -# github-actions +# GitHub Actions Plugin -Welcome to the github-actions plugin! +Website: [https://github.com/actions](https://github.com/actions) -_This plugin was created through the Backstage CLI_ +## Screenshots -## Getting started +TBD -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/github-actions](http://localhost:3000/github-actions). +## Setup -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. +### Generic Requirements + +1. Provide OAuth credentials: + 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with callback URL set to `https://localhost:3000/auth/github`. + 2. Take Client ID and Client Secret from the newly created app's settings page and put them into `AUTH_GITHUB_CLIENT_ID` and `AUTH_GITHUB_CLIENT_SECRET` env variables. +2. Annotate your component with a correct GitHub Actions repository and owner: + + The annotation key is `github.com/project-slug`. + + Example: + + ``` + apiVersion: backstage.io/v1alpha1 + kind: Component + metadata: + name: backstage + description: backstage.io + annotations: + github.com/project-slug: 'spotify/backstage' + + spec: + type: website + lifecycle: production + owner: guest + ``` + +### Standalone app requirements + +If you didn't clone this repo you have to do some extra work. + +1. Add plugin API to your Backstage instance: + +```bash +yarn add @backstage/plugin-github-actions +``` + +```js +// packages/app/src/api.ts +import { ApiRegistry } from '@backstage/core'; +import { GithubActionsClient, githubActionsApiRef } from '@backstage/plugin-github-actions'; + +const builder = ApiRegistry.builder(); +builder.add(githubActionsApiRef, new GithubActionsClient()); + +export default builder.build() as ApiHolder; +``` + +2. Add plugin itself: + +```js +// packages/app/src/plugins.ts +export { plugin as GithubActions } from '@backstage/plugin-github-actions'; +``` + +3. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`. + +## Features + +- List workflow runs for a project +- Dive into one run to see a job steps +- Retry runs +- Pagination for runs + +## Limitations + +- There is a limit of 100 apps for one OAuth client/token pair diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 8ac474169f..55f1aab4de 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -18,28 +18,33 @@ "diff": "backstage-cli plugin:diff", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" + "clean": "backstage-cli clean", + "mock-data": "./scripts/mock-data.sh" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/core-api": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/core-api": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", + "@backstage/plugin-catalog": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@octokit/rest": "^18.0.0", + "@octokit/types": "^5.0.1", + "moment": "^2.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/github-actions/scripts/mock-data.sh b/plugins/github-actions/scripts/mock-data.sh new file mode 100755 index 0000000000..2653a415dd --- /dev/null +++ b/plugins/github-actions/scripts/mock-data.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + + curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/scripts/sample.yaml\"}" + echo diff --git a/plugins/github-actions/scripts/sample.yaml b/plugins/github-actions/scripts/sample.yaml new file mode 100644 index 0000000000..d582bf9c35 --- /dev/null +++ b/plugins/github-actions/scripts/sample.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: backstage.io + annotations: + github.com/project-slug: 'spotify/backstage' +spec: + type: website + lifecycle: production + owner: guest diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index dd75f34bf7..bb54fae703 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -15,7 +15,11 @@ */ import { createApiRef } from '@backstage/core'; -import { Build, BuildDetails } from './types'; +import { + ActionsListWorkflowRunsForRepoResponseData, + ActionsGetWorkflowResponseData, + ActionsGetWorkflowRunResponseData, +} from '@octokit/types'; export const githubActionsApiRef = createApiRef({ id: 'plugin.githubactions.service', @@ -23,14 +27,50 @@ export const githubActionsApiRef = createApiRef({ }); export type GithubActionsApi = { - listBuilds: ({ + listWorkflowRuns: ({ + token, owner, repo, - token, + pageSize, + page, }: { + token: string; owner: string; repo: string; + pageSize?: number; + page?: number; + }) => Promise; + getWorkflow: ({ + token, + owner, + repo, + id, + }: { token: string; - }) => Promise; - getBuild: (buildUri: string, token: Promise) => Promise; + owner: string; + repo: string; + id: number; + }) => Promise; + getWorkflowRun: ({ + token, + owner, + repo, + id, + }: { + token: string; + owner: string; + repo: string; + id: number; + }) => Promise; + reRunWorkflow: ({ + token, + owner, + repo, + runId, + }: { + token: string; + owner: string; + repo: string; + runId: number; + }) => Promise; }; diff --git a/plugins/github-actions/src/api/GithubActionsClient.ts b/plugins/github-actions/src/api/GithubActionsClient.ts index 58d7280c1f..a96f5341fd 100644 --- a/plugins/github-actions/src/api/GithubActionsClient.ts +++ b/plugins/github-actions/src/api/GithubActionsClient.ts @@ -15,117 +15,88 @@ */ import { GithubActionsApi } from './GithubActionsApi'; -import { Build, BuildDetails, BuildStatus, WorkflowRun } from './types'; - -const statusToBuildStatus: { [status: string]: BuildStatus } = { - success: BuildStatus.Success, - failure: BuildStatus.Failure, - pending: BuildStatus.Pending, - running: BuildStatus.Running, - in_progress: BuildStatus.Running, - completed: BuildStatus.Success, -}; - -const conclusionToStatus = (conslusion: string): BuildStatus => - statusToBuildStatus[conslusion] ?? BuildStatus.Null; +import { Octokit } from '@octokit/rest'; +import { + ActionsListWorkflowRunsForRepoResponseData, + ActionsGetWorkflowResponseData, + ActionsGetWorkflowRunResponseData, +} from '@octokit/types'; export class GithubActionsClient implements GithubActionsApi { - async listBuilds({ + async reRunWorkflow({ + token, owner, repo, - token, + runId, }: { + token: string; owner: string; repo: string; - token: string; - }): Promise { - const url = `https://api.github.com/repos/${owner}/${repo}/actions/runs`; - - const response = await fetch(url, { - headers: new Headers({ - Authorization: `Bearer ${token}`, - }), + runId: number; + }): Promise { + return new Octokit({ auth: token }).actions.reRunWorkflow({ + owner, + repo, + run_id: runId, }); - - if (!response.ok) { - return [ - { - commitId: 'Error', - message: 'Response status is not OK', - branch: 'Error', - status: BuildStatus.Failure, - uri: 'Error', - }, - ]; - } - - const data = await response.json(); - - const newData: WorkflowRun[] = data.workflow_runs; - - const endData: Build[] = []; - - newData.forEach((element, index) => { - const transData: Build = { - commitId: '', - message: '', - branch: '', - status: BuildStatus.Null, - uri: '', - }; - transData.commitId = String(element.head_commit.id); - transData.branch = element.head_branch; - transData.status = conclusionToStatus(element.conclusion); - transData.message = element.head_commit.message; - transData.uri = element.url; - endData[index] = transData; - }); - - return endData; } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getBuild( - buildUri: string, - token: Promise, - ): Promise { - const response = await fetch(buildUri, { - headers: new Headers({ - Authorization: `Bearer ${await token}`, - }), + async listWorkflowRuns({ + token, + owner, + repo, + pageSize = 100, + page = 0, + }: { + token: string; + owner: string; + repo: string; + pageSize?: number; + page?: number; + }): Promise { + const workflowRuns = await new Octokit({ + auth: token, + }).actions.listWorkflowRunsForRepo({ + owner, + repo, + per_page: pageSize, + page, }); - const buildBlank: Build = { - commitId: '', - message: '', - branch: '', - status: BuildStatus.Null, - uri: '', - }; - - const dataBlank: BuildDetails = { - build: buildBlank, - author: '', - logUrl: '', - overviewUrl: '', - }; - - if (!response.ok) { - return dataBlank; - } - - const data = await response.json(); - - const newData: WorkflowRun = data; - - dataBlank.author = newData.head_commit.author.name; - dataBlank.build.branch = newData.head_branch; - dataBlank.build.commitId = newData.head_commit.id; - dataBlank.build.message = newData.head_commit.message; - dataBlank.build.status = conclusionToStatus(newData.status); - dataBlank.build.uri = newData.url; - dataBlank.logUrl = newData.logs_url; - dataBlank.overviewUrl = newData.html_url; - - return dataBlank; + return workflowRuns.data; + } + async getWorkflow({ + token, + owner, + repo, + id, + }: { + token: string; + owner: string; + repo: string; + id: number; + }): Promise { + const workflow = await new Octokit({ auth: token }).actions.getWorkflow({ + owner, + repo, + workflow_id: id, + }); + return workflow.data; + } + async getWorkflowRun({ + token, + owner, + repo, + id, + }: { + token: string; + owner: string; + repo: string; + id: number; + }): Promise { + const run = await new Octokit({ auth: token }).actions.getWorkflowRun({ + owner, + repo, + run_id: id, + }); + return run.data; } } diff --git a/plugins/github-actions/src/api/types.ts b/plugins/github-actions/src/api/types.ts index 8a7b2ca548..cd74a20cda 100644 --- a/plugins/github-actions/src/api/types.ts +++ b/plugins/github-actions/src/api/types.ts @@ -14,211 +14,33 @@ * limitations under the License. */ -export enum BuildStatus { - Null, - Success, - Failure, - Pending, - Running, -} - -export type Build = { - commitId: string; - message: string; - branch: string; - status: BuildStatus; - uri: string; -}; - -export type BuildDetails = { - build: Build; - author: string; - logUrl: string; - overviewUrl: string; -}; - -export interface Author { +export type Step = { name: string; - email: string; -} - -export interface Committer { - name: string; - email: string; -} - -export interface HeadCommit { - id: string; - tree_id: string; - message: string; - timestamp: Date; - author: Author; - committer: Committer; -} - -export interface Owner { - login: string; - id: number; - node_id: string; - avatar_url: string; - gravatar_id: string; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: boolean; -} - -export interface Repository { - id: number; - node_id: string; - name: string; - full_name: string; - private: boolean; - owner: Owner; - html_url: string; - description?: any; - fork: boolean; - url: string; - forks_url: string; - keys_url: string; - collaborators_url: string; - teams_url: string; - hooks_url: string; - issue_events_url: string; - events_url: string; - assignees_url: string; - branches_url: string; - tags_url: string; - blobs_url: string; - git_tags_url: string; - git_refs_url: string; - trees_url: string; - statuses_url: string; - languages_url: string; - stargazers_url: string; - contributors_url: string; - subscribers_url: string; - subscription_url: string; - commits_url: string; - git_commits_url: string; - comments_url: string; - issue_comment_url: string; - contents_url: string; - compare_url: string; - merges_url: string; - archive_url: string; - downloads_url: string; - issues_url: string; - pulls_url: string; - milestones_url: string; - notifications_url: string; - labels_url: string; - releases_url: string; - deployments_url: string; -} - -export interface Owner2 { - login: string; - id: number; - node_id: string; - avatar_url: string; - gravatar_id: string; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: boolean; -} - -export interface HeadRepository { - id: number; - node_id: string; - name: string; - full_name: string; - private: boolean; - owner: Owner2; - html_url: string; - description?: any; - fork: boolean; - url: string; - forks_url: string; - keys_url: string; - collaborators_url: string; - teams_url: string; - hooks_url: string; - issue_events_url: string; - events_url: string; - assignees_url: string; - branches_url: string; - tags_url: string; - blobs_url: string; - git_tags_url: string; - git_refs_url: string; - trees_url: string; - statuses_url: string; - languages_url: string; - stargazers_url: string; - contributors_url: string; - subscribers_url: string; - subscription_url: string; - commits_url: string; - git_commits_url: string; - comments_url: string; - issue_comment_url: string; - contents_url: string; - compare_url: string; - merges_url: string; - archive_url: string; - downloads_url: string; - issues_url: string; - pulls_url: string; - milestones_url: string; - notifications_url: string; - labels_url: string; - releases_url: string; - deployments_url: string; -} - -export interface WorkflowRun { - id: number; - node_id: string; - head_branch: string; - head_sha: string; - run_number: number; - event: string; status: string; conclusion: string; - workflow_id: number; - url: string; + number: number; // starts from 1 + started_at: string; + completed_at: string; +}; + +export type Job = { html_url: string; - pull_requests: any[]; - created_at: Date; - updated_at: Date; - jobs_url: string; - logs_url: string; - check_suite_url: string; - artifacts_url: string; - cancel_url: string; - rerun_url: string; - workflow_url: string; - head_commit: HeadCommit; - repository: Repository; - head_repository: HeadRepository; + status: string; + conclusion: string; + started_at: string; + completed_at: string; + name: string; + steps: Step[]; +}; + +export type Jobs = { + total_count: number; + jobs: Job[]; +}; + +export enum BuildStatus { + 'success', + 'failure', + 'pending', + 'running', } diff --git a/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx b/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx deleted file mode 100644 index 707c670f40..0000000000 --- a/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * 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 { - Button, - ButtonGroup, - LinearProgress, - makeStyles, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - Theme, - Typography, -} from '@material-ui/core'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { BuildStatusIndicator } from '../BuildStatusIndicator'; -import { Link, useApi, githubAuthApiRef } from '@backstage/core'; -import { githubActionsApiRef } from '../../api'; - -const useStyles = makeStyles(theme => ({ - root: { - maxWidth: 720, - margin: theme.spacing(2), - }, - title: { - padding: theme.spacing(1, 0, 2, 0), - }, - table: { - padding: theme.spacing(1), - }, -})); - -export const BuildDetailsPage = () => { - const api = useApi(githubActionsApiRef); - const githubApi = useApi(githubAuthApiRef); - const token = githubApi.getAccessToken('repo'); - - const classes = useStyles(); - const location = useLocation(); - const status = useAsync( - () => - api.getBuild(decodeURIComponent(location.search.split('uri=')[1]), token), - [location.search], - ); - - if (status.loading) { - return ; - } else if (status.error) { - return ( - - Failed to load build, {status.error.message} - - ); - } - - const details = status.value; - - return ( -
- - - - < - - - Build Details - - - - - - - Branch - - {details?.build.branch} - - - - Message - - {details?.build.message} - - - - Commit ID - - {details?.build.commitId} - - - - Status - - - - - - - - Author - - {details?.author} - - - - Links - - - - {details?.overviewUrl && ( - - )} - {details?.logUrl && ( - - )} - - - - -
-
-
- ); -}; diff --git a/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx b/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx deleted file mode 100644 index cbbc1da339..0000000000 --- a/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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 { - LinearProgress, - makeStyles, - Table, - TableBody, - TableCell, - TableRow, - Theme, - Typography, -} from '@material-ui/core'; -import React from 'react'; -import { useAsync } from 'react-use'; -import { BuildStatusIndicator } from '../BuildStatusIndicator'; -import { githubActionsApiRef } from '../../api'; -import { Link, useApi, githubAuthApiRef } from '@backstage/core'; - -const useStyles = makeStyles(theme => ({ - root: { - // height: 400, - }, - title: { - paddingBottom: theme.spacing(1), - }, -})); - -const BuildInfoCardContent = () => { - const api = useApi(githubActionsApiRef); - const githubApi = useApi(githubAuthApiRef); - - const status = useAsync(async () => { - const token = await githubApi.getAccessToken('repo'); - return api.listBuilds({ owner: 'spotify', repo: 'backstage', token }); - }); - - if (status.loading) { - return ; - } else if (status.error) { - return ( - - Failed to load builds, {status.error.message} - - ); - } - - const [build] = - status.value?.filter(({ branch }) => branch === 'master') ?? []; - - return ( - - - - - Message - - - - {build?.message} - - - - - - Commit ID - - {build?.commitId} - - - - Status - - - - - - -
- ); -}; - -export const BuildInfoCard = () => { - const classes = useStyles(); - - return ( -
- - Master Build - - -
- ); -}; diff --git a/plugins/github-actions/src/components/BuildListTable/useBuilds.ts b/plugins/github-actions/src/components/BuildListTable/useBuilds.ts deleted file mode 100644 index 2ac5ef98d6..0000000000 --- a/plugins/github-actions/src/components/BuildListTable/useBuilds.ts +++ /dev/null @@ -1,330 +0,0 @@ -/* - * 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 { useCallback, useState } from 'react'; -import { useAsyncRetry } from 'react-use'; -import { Build } from './BuildListTable'; - -// TODO(shmidt-i): use real APIs -const useEntityGHSettings = () => ({ repo: 'test', owner: 'shmidt-i-test' }); -const buildsMock = { - total_count: 1, - workflow_runs: [ - { - id: 30433642, - node_id: 'MDEyOldvcmtmbG93IFJ1bjI2OTI4OQ==', - head_branch: 'master', - head_sha: 'acb5820ced9479c074f688cc328bf03f341a511d', - run_number: 562, - event: 'push', - status: 'queued', - conclusion: null, - workflow_id: 159038, - url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642', - html_url: 'https://github.com/octo-org/octo-repo/actions/runs/30433642', - pull_requests: [], - created_at: '2020-01-22T19:33:08Z', - updated_at: '2020-01-22T19:33:08Z', - jobs_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/jobs', - logs_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/logs', - check_suite_url: - 'https://api.github.com/repos/octo-org/octo-repo/check-suites/414944374', - artifacts_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/artifacts', - cancel_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/cancel', - rerun_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/rerun', - workflow_url: - 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/159038', - head_commit: { - id: 'acb5820ced9479c074f688cc328bf03f341a511d', - tree_id: 'd23f6eedb1e1b9610bbc754ddb5197bfe7271223', - message: 'Create linter.yml', - timestamp: '2020-01-22T19:33:05Z', - author: { - name: 'Octo Cat', - email: 'octocat@github.com', - }, - committer: { - name: 'GitHub', - email: 'noreply@github.com', - }, - }, - repository: { - id: 1296269, - node_id: 'MDEwOlJlcG9zaXRvcnkxMjk2MjY5', - name: 'Hello-World', - full_name: 'octocat/Hello-World', - owner: { - login: 'octocat', - id: 1, - node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - gravatar_id: '', - url: 'https://api.github.com/users/octocat', - html_url: 'https://github.com/octocat', - followers_url: 'https://api.github.com/users/octocat/followers', - following_url: - 'https://api.github.com/users/octocat/following{/other_user}', - gists_url: 'https://api.github.com/users/octocat/gists{/gist_id}', - starred_url: - 'https://api.github.com/users/octocat/starred{/owner}{/repo}', - subscriptions_url: - 'https://api.github.com/users/octocat/subscriptions', - organizations_url: 'https://api.github.com/users/octocat/orgs', - repos_url: 'https://api.github.com/users/octocat/repos', - events_url: 'https://api.github.com/users/octocat/events{/privacy}', - received_events_url: - 'https://api.github.com/users/octocat/received_events', - type: 'User', - site_admin: false, - }, - private: false, - html_url: 'https://github.com/octocat/Hello-World', - description: 'This your first repo!', - fork: false, - url: 'https://api.github.com/repos/octocat/Hello-World', - archive_url: - 'http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}', - assignees_url: - 'http://api.github.com/repos/octocat/Hello-World/assignees{/user}', - blobs_url: - 'http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}', - branches_url: - 'http://api.github.com/repos/octocat/Hello-World/branches{/branch}', - collaborators_url: - 'http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}', - comments_url: - 'http://api.github.com/repos/octocat/Hello-World/comments{/number}', - commits_url: - 'http://api.github.com/repos/octocat/Hello-World/commits{/sha}', - compare_url: - 'http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}', - contents_url: - 'http://api.github.com/repos/octocat/Hello-World/contents/{+path}', - contributors_url: - 'http://api.github.com/repos/octocat/Hello-World/contributors', - deployments_url: - 'http://api.github.com/repos/octocat/Hello-World/deployments', - downloads_url: - 'http://api.github.com/repos/octocat/Hello-World/downloads', - events_url: 'http://api.github.com/repos/octocat/Hello-World/events', - forks_url: 'http://api.github.com/repos/octocat/Hello-World/forks', - git_commits_url: - 'http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}', - git_refs_url: - 'http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}', - git_tags_url: - 'http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}', - git_url: 'git:github.com/octocat/Hello-World.git', - issue_comment_url: - 'http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}', - issue_events_url: - 'http://api.github.com/repos/octocat/Hello-World/issues/events{/number}', - issues_url: - 'http://api.github.com/repos/octocat/Hello-World/issues{/number}', - keys_url: - 'http://api.github.com/repos/octocat/Hello-World/keys{/key_id}', - labels_url: - 'http://api.github.com/repos/octocat/Hello-World/labels{/name}', - languages_url: - 'http://api.github.com/repos/octocat/Hello-World/languages', - merges_url: 'http://api.github.com/repos/octocat/Hello-World/merges', - milestones_url: - 'http://api.github.com/repos/octocat/Hello-World/milestones{/number}', - notifications_url: - 'http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}', - pulls_url: - 'http://api.github.com/repos/octocat/Hello-World/pulls{/number}', - releases_url: - 'http://api.github.com/repos/octocat/Hello-World/releases{/id}', - ssh_url: 'git@github.com:octocat/Hello-World.git', - stargazers_url: - 'http://api.github.com/repos/octocat/Hello-World/stargazers', - statuses_url: - 'http://api.github.com/repos/octocat/Hello-World/statuses/{sha}', - subscribers_url: - 'http://api.github.com/repos/octocat/Hello-World/subscribers', - subscription_url: - 'http://api.github.com/repos/octocat/Hello-World/subscription', - tags_url: 'http://api.github.com/repos/octocat/Hello-World/tags', - teams_url: 'http://api.github.com/repos/octocat/Hello-World/teams', - trees_url: - 'http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}', - }, - head_repository: { - id: 217723378, - node_id: 'MDEwOlJlcG9zaXRvcnkyMTc3MjMzNzg=', - name: 'octo-repo', - full_name: 'octo-org/octo-repo', - private: true, - owner: { - login: 'octocat', - id: 1, - node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - gravatar_id: '', - url: 'https://api.github.com/users/octocat', - html_url: 'https://github.com/octocat', - followers_url: 'https://api.github.com/users/octocat/followers', - following_url: - 'https://api.github.com/users/octocat/following{/other_user}', - gists_url: 'https://api.github.com/users/octocat/gists{/gist_id}', - starred_url: - 'https://api.github.com/users/octocat/starred{/owner}{/repo}', - subscriptions_url: - 'https://api.github.com/users/octocat/subscriptions', - organizations_url: 'https://api.github.com/users/octocat/orgs', - repos_url: 'https://api.github.com/users/octocat/repos', - events_url: 'https://api.github.com/users/octocat/events{/privacy}', - received_events_url: - 'https://api.github.com/users/octocat/received_events', - type: 'User', - site_admin: false, - }, - html_url: 'https://github.com/octo-org/octo-repo', - description: null, - fork: false, - url: 'https://api.github.com/repos/octo-org/octo-repo', - forks_url: 'https://api.github.com/repos/octo-org/octo-repo/forks', - keys_url: - 'https://api.github.com/repos/octo-org/octo-repo/keys{/key_id}', - collaborators_url: - 'https://api.github.com/repos/octo-org/octo-repo/collaborators{/collaborator}', - teams_url: 'https://api.github.com/repos/octo-org/octo-repo/teams', - hooks_url: 'https://api.github.com/repos/octo-org/octo-repo/hooks', - issue_events_url: - 'https://api.github.com/repos/octo-org/octo-repo/issues/events{/number}', - events_url: 'https://api.github.com/repos/octo-org/octo-repo/events', - assignees_url: - 'https://api.github.com/repos/octo-org/octo-repo/assignees{/user}', - branches_url: - 'https://api.github.com/repos/octo-org/octo-repo/branches{/branch}', - tags_url: 'https://api.github.com/repos/octo-org/octo-repo/tags', - blobs_url: - 'https://api.github.com/repos/octo-org/octo-repo/git/blobs{/sha}', - git_tags_url: - 'https://api.github.com/repos/octo-org/octo-repo/git/tags{/sha}', - git_refs_url: - 'https://api.github.com/repos/octo-org/octo-repo/git/refs{/sha}', - trees_url: - 'https://api.github.com/repos/octo-org/octo-repo/git/trees{/sha}', - statuses_url: - 'https://api.github.com/repos/octo-org/octo-repo/statuses/{sha}', - languages_url: - 'https://api.github.com/repos/octo-org/octo-repo/languages', - stargazers_url: - 'https://api.github.com/repos/octo-org/octo-repo/stargazers', - contributors_url: - 'https://api.github.com/repos/octo-org/octo-repo/contributors', - subscribers_url: - 'https://api.github.com/repos/octo-org/octo-repo/subscribers', - subscription_url: - 'https://api.github.com/repos/octo-org/octo-repo/subscription', - commits_url: - 'https://api.github.com/repos/octo-org/octo-repo/commits{/sha}', - git_commits_url: - 'https://api.github.com/repos/octo-org/octo-repo/git/commits{/sha}', - comments_url: - 'https://api.github.com/repos/octo-org/octo-repo/comments{/number}', - issue_comment_url: - 'https://api.github.com/repos/octo-org/octo-repo/issues/comments{/number}', - contents_url: - 'https://api.github.com/repos/octo-org/octo-repo/contents/{+path}', - compare_url: - 'https://api.github.com/repos/octo-org/octo-repo/compare/{base}...{head}', - merges_url: 'https://api.github.com/repos/octo-org/octo-repo/merges', - archive_url: - 'https://api.github.com/repos/octo-org/octo-repo/{archive_format}{/ref}', - downloads_url: - 'https://api.github.com/repos/octo-org/octo-repo/downloads', - issues_url: - 'https://api.github.com/repos/octo-org/octo-repo/issues{/number}', - pulls_url: - 'https://api.github.com/repos/octo-org/octo-repo/pulls{/number}', - milestones_url: - 'https://api.github.com/repos/octo-org/octo-repo/milestones{/number}', - notifications_url: - 'https://api.github.com/repos/octo-org/octo-repo/notifications{?since,all,participating}', - labels_url: - 'https://api.github.com/repos/octo-org/octo-repo/labels{/name}', - releases_url: - 'https://api.github.com/repos/octo-org/octo-repo/releases{/id}', - deployments_url: - 'https://api.github.com/repos/octo-org/octo-repo/deployments', - }, - }, - ], -}; - -export function useBuilds() { - const { repo, owner } = useEntityGHSettings(); - - const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(5); - const getBuilds = useCallback(async () => buildsMock, []); - - const restartBuild = async () => {}; - - const { loading, value: builds, retry } = useAsyncRetry( - () => - getBuilds().then((allBuilds): Build[] => { - setTotal(allBuilds.total_count); - // Transformation here - return allBuilds.workflow_runs.map(run => ({ - buildName: run.head_commit.message, - id: `${run.id}`, - onRestartClick: () => {}, - source: { - branchName: run.head_branch, - commit: { - hash: run.head_commit.id, - url: run.head_repository.branches_url.replace( - '{/branch}', - run.head_branch, - ), - }, - }, - status: run.status, - buildUrl: run.url, - })); - }), - [page, pageSize, getBuilds], - ); - - const projectName = `${owner}/${repo}`; - return [ - { - page, - pageSize, - loading, - builds, - projectName, - total, - }, - { - getBuilds, - setPage, - setPageSize, - restartBuild, - retry, - }, - ] as const; -} diff --git a/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx b/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx deleted file mode 100644 index 1a198f4df2..0000000000 --- a/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 { IconComponent } from '@backstage/core'; -import { makeStyles, Theme } from '@material-ui/core'; -import ProgressIcon from '@material-ui/icons/Autorenew'; -import SuccessIcon from '@material-ui/icons/CheckCircle'; -import FailureIcon from '@material-ui/icons/Error'; -import UnknownIcon from '@material-ui/icons/Help'; -import React from 'react'; -import { BuildStatus } from '../../api/types'; - -type Props = { - status?: BuildStatus; -}; - -type StatusStyle = { - icon: IconComponent; - color: string; -}; - -const styles: { [key in BuildStatus]: StatusStyle } = { - [BuildStatus.Null]: { - icon: UnknownIcon, - color: '#f49b20', - }, - [BuildStatus.Success]: { - icon: SuccessIcon, - color: '#1db855', - }, - [BuildStatus.Failure]: { - icon: FailureIcon, - color: '#CA001B', - }, - [BuildStatus.Pending]: { - icon: UnknownIcon, - color: '#5BC0DE', - }, - [BuildStatus.Running]: { - icon: ProgressIcon, - color: '#BEBEBE', - }, -}; - -const useStyles = makeStyles({ - icon: style => ({ - color: style.color, - }), -}); - -export const BuildStatusIndicator = ({ status }: Props) => { - const style = (status && styles[status]) || styles[BuildStatus.Null]; - const classes = useStyles(style); - const Icon = style.icon; - - return ( -
- -
- ); -}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx b/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx new file mode 100644 index 0000000000..283fd60ec6 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx @@ -0,0 +1,276 @@ +/* + * 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 { + Button, + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Theme, + Typography, + Box, + ExpansionPanelDetails, + ExpansionPanel, + ExpansionPanelSummary, + ListItemText, + CircularProgress, + Grid, + Breadcrumbs, +} from '@material-ui/core'; +import moment from 'moment'; + +import React from 'react'; +import { + Link, + Page, + Header, + HeaderLabel, + Content, + ContentHeader, + SupportButton, + pageTheme, +} from '@backstage/core'; +import { Job, Step, Jobs } from '../../api/types'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { useWorkflowRunsDetails } from './useWorkflowRunsDetails'; +import { useWorkflowRunJobs } from './useWorkflowRunJobs'; +import { WorkflowRunStatusIcon } from '../WorkflowRunStatusIcon/WorkflowRunStatusIcon'; +import { useProjectName } from '../useProjectName'; +import GitHubIcon from '@material-ui/icons/GitHub'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, + table: { + padding: theme.spacing(1), + }, + expansionPanelDetails: { + padding: 0, + }, + button: { + order: -1, + marginRight: 0, + marginLeft: '-20px', + }, +})); + +const JobsList = ({ jobs }: { jobs?: Jobs }) => { + const classes = useStyles(); + return ( + + {jobs && + jobs.total_count > 0 && + jobs.jobs.map((job: Job) => ( + + ))} + + ); +}; + +const getElapsedTime = (start: string, end: string) => { + const diff = moment(moment(end || moment()).diff(moment(start))); + const timeElapsed = diff.format('m [minutes] s [seconds]'); + return timeElapsed; +}; + +const StepView = ({ step }: { step: Step }) => { + return ( + + + + + + + {step.status} + + + ); +}; + +const JobListItem = ({ job, className }: { job: Job; className: string }) => { + const classes = useStyles(); + return ( + + } + aria-controls={`panel-${name}-content`} + id={`panel-${name}-header`} + IconButtonProps={{ + className: classes.button, + }} + > + + {job.name} ({getElapsedTime(job.started_at, job.completed_at)}) + + + + + + {job.steps.map((step: Step) => ( + + ))} +
+
+
+
+ ); +}; + +/** + * A component for Jobs visualization. Jobs are a property of a Workflow Run. + */ +export const WorkflowRunDetailsPage = () => { + const [owner, repo] = ( + useProjectName({ + kind: 'Component', + name: 'backstage', + }) ?? '/' + ).split('/'); + const details = useWorkflowRunsDetails(repo, owner); + const jobs = useWorkflowRunJobs(details.value?.jobs_url); + + const classes = useStyles(); + + if (details.loading) { + return ; + } else if (details.error) { + return ( + + Failed to load build, {details.error.message} + + ); + } + + return ( + +
+ + +
+ + + + This plugin allows you to view and interact with your builds within + the GitHub Actions environment. + + + + Workflow runs + Workflow run details + + + +
+ + + + + + Branch + + {details.value?.head_branch} + + + + Message + + + {details.value?.head_commit.message} + + + + + Commit ID + + {details.value?.head_commit.id} + + + + Status + + + {' '} + {details.value?.status.toUpperCase()} + + + + + Author + + {`${details.value?.head_commit.author.name} (${details.value?.head_commit.author.email})`} + + + + Links + + + {details.value?.html_url && ( + + + + )} + + + + + Jobs + {jobs.loading ? ( + + ) : ( + + )} + + + +
+
+
+
+
+
+
+ ); +}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts b/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts new file mode 100644 index 0000000000..443bcacc88 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage'; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunJobs.ts b/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunJobs.ts new file mode 100644 index 0000000000..d4c09abf7d --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunJobs.ts @@ -0,0 +1,26 @@ +/* + * 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 { useAsync } from 'react-use'; +import { Jobs } from '../../api/types'; + +export const useWorkflowRunJobs = (jobsUrl?: string) => { + const jobs = useAsync(async () => { + if (jobsUrl === undefined) return []; + const data = await fetch(jobsUrl).then(d => d.json()); + return data; + }, [jobsUrl]); + return jobs; +}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunsDetails.ts b/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunsDetails.ts new file mode 100644 index 0000000000..623f55eea6 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunDetailsPage/useWorkflowRunsDetails.ts @@ -0,0 +1,35 @@ +/* + * 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 { useApi, githubAuthApiRef } from '@backstage/core'; +import { useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { githubActionsApiRef } from '../../api'; + +export const useWorkflowRunsDetails = (repo: string, owner: string) => { + const api = useApi(githubActionsApiRef); + const auth = useApi(githubAuthApiRef); + const { id } = useParams(); + const details = useAsync(async () => { + const token = await auth.getAccessToken(['repo']); + return api.getWorkflowRun({ + token, + owner, + repo, + id: parseInt(id, 10), + }); + }, [repo, owner, id]); + return details; +}; diff --git a/plugins/github-actions/src/components/WorkflowRunStatusIcon/WorkflowRunStatusIcon.tsx b/plugins/github-actions/src/components/WorkflowRunStatusIcon/WorkflowRunStatusIcon.tsx new file mode 100644 index 0000000000..645224ad24 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunStatusIcon/WorkflowRunStatusIcon.tsx @@ -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 { StatusPending, StatusRunning, StatusOK } from '@backstage/core'; +import React from 'react'; + +export const WorkflowRunStatusIcon = ({ + status, +}: { + status: string | undefined; +}) => { + if (status === undefined) return null; + switch (status.toLowerCase()) { + case 'queued': + return ; + case 'in_progress': + return ; + case 'completed': + return ; + default: + return ; + } +}; diff --git a/plugins/github-actions/src/components/BuildStatusIndicator/index.ts b/plugins/github-actions/src/components/WorkflowRunStatusIcon/index.ts similarity index 90% rename from plugins/github-actions/src/components/BuildStatusIndicator/index.ts rename to plugins/github-actions/src/components/WorkflowRunStatusIcon/index.ts index 520de525ec..05fff3f46d 100644 --- a/plugins/github-actions/src/components/BuildStatusIndicator/index.ts +++ b/plugins/github-actions/src/components/WorkflowRunStatusIcon/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { BuildStatusIndicator } from './BuildStatusIndicator'; +export { WorkflowRunStatusIcon } from './WorkflowRunStatusIcon'; diff --git a/plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx b/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx similarity index 85% rename from plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx rename to plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx index 9f6807d8bd..b75a70f326 100644 --- a/plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx @@ -26,20 +26,20 @@ import { import { Grid } from '@material-ui/core'; import React from 'react'; -import { BuildListTable } from '../BuildListTable'; +import { WorkflowRunsTable } from '../WorkflowRunsTable'; -export const BuildListPage = () => { +export const WorkflowRunsPage = () => { return (
- + This plugin allows you to view and interact with your builds within the GitHub Actions environment. @@ -47,7 +47,7 @@ export const BuildListPage = () => { - + diff --git a/plugins/github-actions/src/components/BuildListPage/index.ts b/plugins/github-actions/src/components/WorkflowRunsPage/index.ts similarity index 91% rename from plugins/github-actions/src/components/BuildListPage/index.ts rename to plugins/github-actions/src/components/WorkflowRunsPage/index.ts index 2134913e51..ef511763f7 100644 --- a/plugins/github-actions/src/components/BuildListPage/index.ts +++ b/plugins/github-actions/src/components/WorkflowRunsPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { BuildListPage } from './BuildListPage'; +export { WorkflowRunsPage } from './WorkflowRunsPage'; diff --git a/plugins/github-actions/src/components/BuildListTable/BuildListTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx similarity index 57% rename from plugins/github-actions/src/components/BuildListTable/BuildListTable.tsx rename to plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index dc593afdbc..313a51e28a 100644 --- a/plugins/github-actions/src/components/BuildListTable/BuildListTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -14,25 +14,19 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton } from '@material-ui/core'; +import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; import { Link as RouterLink } from 'react-router-dom'; -import { - StatusError, - StatusWarning, - StatusOK, - StatusPending, - StatusRunning, - Table, - TableColumn, -} from '@backstage/core'; -import { useBuilds } from './useBuilds'; +import { Table, TableColumn } from '@backstage/core'; +import { useWorkflowRuns } from './useWorkflowRuns'; +import { WorkflowRunStatusIcon } from '../WorkflowRunStatusIcon'; +import SyncIcon from '@material-ui/icons/Sync'; -export type Build = { +export type WorkflowRun = { id: string; - buildName: string; - buildUrl?: string; + message: string; + url?: string; source: { branchName: string; commit: { @@ -41,25 +35,7 @@ export type Build = { }; }; status: string; - onRestartClick: () => void; -}; - -// retried, canceled, infrastructure_fail, timedout, not_run, running, failed, queued, scheduled, not_running, no_tests, fixed, success -const getStatusComponent = (status: string | undefined = '') => { - switch (status.toLowerCase()) { - case 'queued': - case 'scheduled': - return ; - case 'running': - return ; - case 'failed': - return ; - case 'success': - return ; - case 'canceled': - default: - return ; - } + onReRunClick: () => void; }; const generatedColumns: TableColumn[] = [ @@ -70,40 +46,47 @@ const generatedColumns: TableColumn[] = [ width: '150px', }, { - title: 'Build', - field: 'buildName', + title: 'Message', + field: 'message', highlight: true, - render: (row: Partial) => ( - - {row.buildName} + render: (row: Partial) => ( + + {row.message} ), }, { title: 'Source', - render: (row: Partial) => ( - <> + render: (row: Partial) => ( +

{row.source?.branchName}

{row.source?.commit.hash}

- +
), }, { title: 'Status', - render: (row: Partial) => ( + render: (row: Partial) => ( - {getStatusComponent(row.status)} + - {row.status} + + {row.status} + ), }, { title: 'Actions', - render: (row: Partial) => ( - - - + render: (row: Partial) => ( + + + + + ), width: '10%', }, @@ -112,7 +95,7 @@ const generatedColumns: TableColumn[] = [ type Props = { loading: boolean; retry: () => void; - builds?: Build[]; + runs?: WorkflowRun[]; projectName: string; page: number; onChangePage: (page: number) => void; @@ -121,13 +104,13 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -const BuildListTableView: FC = ({ +const WorkflowRunsTableView: FC = ({ projectName, loading, pageSize, page, retry, - builds, + runs, onChangePage, onChangePageSize, total, @@ -140,13 +123,13 @@ const BuildListTableView: FC = ({ page={page} actions={[ { - icon: () => , - tooltip: 'Refresh Data', + icon: () => , + tooltip: 'Reload workflow runs', isFreeAction: true, onClick: () => retry(), }, ]} - data={builds ?? []} + data={runs ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} title={ @@ -161,16 +144,14 @@ const BuildListTableView: FC = ({ ); }; -const noop = () => {}; - -export const BuildListTable = () => { - const [tableProps] = useBuilds(); +export const WorkflowRunsTable = () => { + const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns(); return ( - ); }; diff --git a/plugins/github-actions/src/components/BuildListTable/index.ts b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts similarity index 84% rename from plugins/github-actions/src/components/BuildListTable/index.ts rename to plugins/github-actions/src/components/WorkflowRunsTable/index.ts index 089d53db94..4b96ba0499 100644 --- a/plugins/github-actions/src/components/BuildListTable/index.ts +++ b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { BuildListTable } from './BuildListTable'; -export type { Build } from './BuildListTable'; +export { WorkflowRunsTable } from './WorkflowRunsTable'; +export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/useWorkflowRuns.ts b/plugins/github-actions/src/components/WorkflowRunsTable/useWorkflowRuns.ts new file mode 100644 index 0000000000..4f72f88de0 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunsTable/useWorkflowRuns.ts @@ -0,0 +1,102 @@ +/* + * 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 { useState } from 'react'; +import { useAsyncRetry } from 'react-use'; +import { WorkflowRun } from './WorkflowRunsTable'; +import { githubActionsApiRef } from '../../api/GithubActionsApi'; +import { useApi, githubAuthApiRef, errorApiRef } from '@backstage/core'; +import { ActionsListWorkflowRunsForRepoResponseData } from '@octokit/types'; +import { useProjectName } from '../useProjectName'; + +export function useWorkflowRuns() { + const api = useApi(githubActionsApiRef); + const auth = useApi(githubAuthApiRef); + + const errorApi = useApi(errorApiRef); + + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const projectName = useProjectName({ + kind: 'Component', + name: 'backstage', + }); + const { loading, value: runs, retry } = useAsyncRetry< + WorkflowRun[] + >(async () => { + const token = await auth.getAccessToken(['repo']); + const [owner, repo] = (projectName ?? '/').split('/'); + return ( + api + // GitHub API pagination count starts from 1 + .listWorkflowRuns({ token, owner, repo, pageSize, page: page + 1 }) + .then( + ( + workflowRunsData: ActionsListWorkflowRunsForRepoResponseData, + ): WorkflowRun[] => { + setTotal(workflowRunsData.total_count); + // Transformation here + return workflowRunsData.workflow_runs.map(run => ({ + message: run.head_commit.message, + id: `${run.id}`, + onReRunClick: async () => { + try { + await api.reRunWorkflow({ + token, + owner, + repo, + runId: run.id, + }); + } catch (e) { + errorApi.post(e); + } + }, + source: { + branchName: run.head_branch, + commit: { + hash: run.head_commit.id, + url: run.head_repository.branches_url.replace( + '{/branch}', + run.head_branch, + ), + }, + }, + status: run.status, + url: run.url, + })); + }, + ) + ); + }, [page, pageSize, projectName]); + + return [ + { + page, + pageSize, + loading, + runs, + projectName: projectName ?? '', + total, + }, + { + runs, + setPage, + setPageSize, + retry, + }, + ] as const; +} diff --git a/plugins/github-actions/src/components/useProjectName.ts b/plugins/github-actions/src/components/useProjectName.ts new file mode 100644 index 0000000000..1624b592bd --- /dev/null +++ b/plugins/github-actions/src/components/useProjectName.ts @@ -0,0 +1,29 @@ +/* + * 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 { useAsync } from 'react-use'; +import { catalogApiRef, EntityCompoundName } from '@backstage/plugin-catalog'; +import { useApi } from '@backstage/core'; + +export const useProjectName = (name: EntityCompoundName) => { + const catalogApi = useApi(catalogApiRef); + + const { value } = useAsync(async () => { + const entity = await catalogApi.getEntityByName(name); + return entity?.metadata.annotations?.['github.com/project-slug'] ?? ''; + }); + return value; +}; diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index 8897fe53db..7faee4e624 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -15,8 +15,8 @@ */ import { createPlugin, createRouteRef } from '@backstage/core'; -import { BuildDetailsPage } from './components/BuildDetailsPage'; -import { BuildListPage } from './components/BuildListPage'; +import { WorkflowRunDetailsPage } from './components/WorkflowRunDetailsPage'; +import { WorkflowRunsPage } from './components/WorkflowRunsPage'; // TODO(freben): This is just a demo route for now export const rootRouteRef = createRouteRef({ @@ -24,14 +24,14 @@ export const rootRouteRef = createRouteRef({ title: 'GitHub Actions', }); export const buildRouteRef = createRouteRef({ - path: '/github-actions/builds', - title: 'GitHub Actions Build', + path: '/github-actions/workflow-run/:id', + title: 'GitHub Actions Workflow Run', }); export const plugin = createPlugin({ id: 'github-actions', register({ router }) { - router.addRoute(rootRouteRef, BuildListPage); - router.addRoute(buildRouteRef, BuildDetailsPage); + router.addRoute(rootRouteRef, WorkflowRunsPage); + router.addRoute(buildRouteRef, WorkflowRunDetailsPage); }, }); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 108f37b381..8157cfe519 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,12 +32,12 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index b9bc339f22..193fe3d133 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "private": false, "publishConfig": { "access": "public", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,14 +43,14 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/codemirror": "^0.0.96", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", "react-router-dom": "6.0.0-beta.0" diff --git a/plugins/graphql/.eslintrc.js b/plugins/graphql/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/graphql/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/graphql/README.md b/plugins/graphql/README.md new file mode 100644 index 0000000000..3f85ec766e --- /dev/null +++ b/plugins/graphql/README.md @@ -0,0 +1,13 @@ +# graphql + +Welcome to the graphql backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/graphql](http://localhost:3000/graphql). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json new file mode 100644 index 0000000000..42f3dc9046 --- /dev/null +++ b/plugins/graphql/package.json @@ -0,0 +1,45 @@ +{ + "name": "@backstage/plugin-graphql-backend", + "version": "0.1.1-alpha.16", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.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", + "mock-data": "./scripts/mock-data.sh" + }, + "dependencies": { + "@backstage/backend-common": "^0.1.1-alpha.16", + "@types/express": "^4.17.6", + "apollo-server": "^2.16.0", + "apollo-server-express": "^2.16.0", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "graphql": "^15.3.0", + "whatwg-fetch": "^2.0.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.16", + "@types/supertest": "^2.0.8", + "eslint-plugin-graphql": "^4.0.0", + "msw": "^0.19.5", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/graphql/scripts/mock-data.sh b/plugins/graphql/scripts/mock-data.sh new file mode 100755 index 0000000000..ff30921715 --- /dev/null +++ b/plugins/graphql/scripts/mock-data.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "use this script to load your service with some mock data if needed!" diff --git a/plugins/github-actions/src/components/BuildDetailsPage/index.ts b/plugins/graphql/src/index.ts similarity index 90% rename from plugins/github-actions/src/components/BuildDetailsPage/index.ts rename to plugins/graphql/src/index.ts index d59d0fc396..f12e894c84 100644 --- a/plugins/github-actions/src/components/BuildDetailsPage/index.ts +++ b/plugins/graphql/src/index.ts @@ -14,4 +14,6 @@ * limitations under the License. */ -export { BuildDetailsPage } from './BuildDetailsPage'; +require('whatwg-fetch'); + +export * from './service/router'; diff --git a/plugins/graphql/src/run.ts b/plugins/graphql/src/run.ts new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/plugins/graphql/src/run.ts @@ -0,0 +1,33 @@ +/* + * 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/graphql/src/schema.gql b/plugins/graphql/src/schema.gql new file mode 100644 index 0000000000..5568182f7f --- /dev/null +++ b/plugins/graphql/src/schema.gql @@ -0,0 +1,11 @@ +type CatalogEntity { + id: String +} + +type CatalogQuery { + list: [CatalogEntity!]! +} + +type Query { + catalog: CatalogQuery! +} diff --git a/plugins/graphql/src/service/router.test.ts b/plugins/graphql/src/service/router.test.ts new file mode 100644 index 0000000000..30bcb8e25d --- /dev/null +++ b/plugins/graphql/src/service/router.test.ts @@ -0,0 +1,22 @@ +/* + * 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 './router'; + +describe('Router', () => { + it('should pass the test', () => { + expect(true).toBeTruthy(); + }); +}); diff --git a/plugins/graphql/src/service/router.ts b/plugins/graphql/src/service/router.ts new file mode 100644 index 0000000000..09ab5270b7 --- /dev/null +++ b/plugins/graphql/src/service/router.ts @@ -0,0 +1,50 @@ +/* + * 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 { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import fs from 'fs'; +import path from 'path'; +import { ApolloServer } from 'apollo-server-express'; + +export interface RouterOptions { + logger: Logger; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const typeDefs = await fs.promises.readFile( + path.resolve(__dirname, '..', 'schema.gql'), + 'utf-8', + ); + + const server = new ApolloServer({ typeDefs, logger: options.logger }); + const router = Router(); + + const apolloMiddlware = server.getMiddleware({ path: '/' }); + router.use(apolloMiddlware); + + router.get('/health', (_, response) => { + response.send({ status: 'ok' }); + }); + + router.use(errorHandler()); + + return router; +} diff --git a/plugins/graphql/src/service/standaloneServer.ts b/plugins/graphql/src/service/standaloneServer.ts new file mode 100644 index 0000000000..0f772a583f --- /dev/null +++ b/plugins/graphql/src/service/standaloneServer.ts @@ -0,0 +1,62 @@ +/* + * 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. + */ +/* + * 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 { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'graphql-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/graphql', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/github-actions/src/components/BuildInfoCard/index.ts b/plugins/graphql/src/setupTests.ts similarity index 92% rename from plugins/github-actions/src/components/BuildInfoCard/index.ts rename to plugins/graphql/src/setupTests.ts index a6409e682a..ac41ef995a 100644 --- a/plugins/github-actions/src/components/BuildInfoCard/index.ts +++ b/plugins/graphql/src/setupTests.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +require('whatwg-fetch'); -export { BuildInfoCard } from './BuildInfoCard'; +export {}; diff --git a/plugins/identity-backend/package.json b/plugins/identity-backend/package.json index 4f3516120c..2e049b78df 100644 --- a/plugins/identity-backend/package.json +++ b/plugins/identity-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-identity-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", @@ -33,7 +33,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "jest-fetch-mock": "^3.0.3" }, "files": [ diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index c87d480c62..eea841c174 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,13 +33,13 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/proxy-backend/README.md b/plugins/proxy-backend/README.md index 315178dac6..d12031f495 100644 --- a/plugins/proxy-backend/README.md +++ b/plugins/proxy-backend/README.md @@ -14,13 +14,13 @@ To run it within the backend do: 1. Register the router in `packages/backend/src/index.ts`: -``` +```ts const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const service = createServiceBuilder(module) - .loadConfig(configReader) - /** several different routers */ - .addRouter('/', await proxy(proxyEnv)); + .loadConfig(configReader) + /** several different routers */ + .addRouter('/', await proxy(proxyEnv)); ``` 2. Start the backend @@ -33,5 +33,4 @@ This will launch the full example backend. ## Links -- (http-proxy-middleware)[https://www.npmjs.com/package/http-proxy-middleware] -- (The Backstage homepage)[https://backstage.io] +- [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware) diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 9a9352ba83..e80bf0e00f 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-proxy-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,9 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", - "@backstage/config": "^0.1.1-alpha.13", - "@backstage/config-loader": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", + "@backstage/config": "^0.1.1-alpha.16", + "@backstage/config-loader": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -35,7 +35,7 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 2c8af574bd..b71299ef7e 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -31,7 +31,7 @@ export async function createRouter( const router = Router(); const proxyConfig = options.config.get('proxy') ?? {}; Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => { - router.use(createProxyMiddleware(route, proxyRouteConfig)); + router.use(route, createProxyMiddleware(proxyRouteConfig)); }); return router; diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index dabd591bd0..e1a095f1e4 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/plugin-catalog": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/plugin-catalog": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,12 +36,12 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 5033bdbdea..2b2577fa73 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "axios": "^0.19.2", "camelcase-keys": "^6.2.2", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@types/supertest": "^2.0.8", "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 0645d62c9c..d2308b869e 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,14 +35,14 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index df4f64060f..9dee285320 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "mock-data": "./scripts/mock-data.sh" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.13", - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/config": "^0.1.1-alpha.13", + "@backstage/backend-common": "^0.1.1-alpha.16", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/config": "^0.1.1-alpha.16", "@octokit/rest": "^18.0.0", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", @@ -39,10 +39,11 @@ "morgan": "^1.10.0", "nodegit": "0.26.5", "uuid": "^8.2.0", - "winston": "^3.2.1" + "winston": "^3.2.1", + "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", "@octokit/types": "^5.0.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml new file mode 100644 index 0000000000..4d730fb702 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -0,0 +1,34 @@ +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: create-react-app-template + title: Create React App Template + description: A template that scaffolds an applications using the Create React App (CRA) templater. + tags: + - Experimental + - React + - CRA +spec: + owner: web@example.com + templater: cra + type: website + path: '.' + + schema: + required: + - component_id + - use_typescript + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Description of the component + use_typescript: + title: Use Typescript + type: boolean + description: Include typescript + default: true diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml index 363d488448..ba0e877662 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml @@ -8,11 +8,12 @@ metadata: - Recommended - React spec: + owner: web@example.com templater: cookiecutter type: website path: '.' schema: - required: + required: - component_id - description properties: @@ -21,7 +22,6 @@ spec: type: string description: Unique name of the component description: - title: Description + title: Description type: string description: Description of the component - diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/cookiecutter.json b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/cookiecutter.json new file mode 100644 index 0000000000..d662b6f46b --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/cookiecutter.json @@ -0,0 +1,7 @@ +{ + "component_id": "", + "artifact_id": "{{ cookiecutter.component_id }}", + "java_package_name": "{{ cookiecutter.component_id|replace('-', '') }}", + "description": "We promise to update this description /{{cookiecutter.owner}}", + "http_port": 8080 +} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml similarity index 70% rename from plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml rename to plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml index 19ec7935d9..b1bc273d88 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml @@ -2,12 +2,13 @@ apiVersion: backstage.io/v1alpha1 kind: Template metadata: name: springboot-template - title: Spring Boot Service - description: Standard Spring Boot (Java) microservice with recommended configuration. + title: Spring Boot GRPC Service + description: Standard Spring Boot (Java) microservice with recommended configuration for GRPC tags: - Recommended - Java spec: + owner: service@example.com templater: cookiecutter type: service path: '.' @@ -24,3 +25,8 @@ spec: title: Description type: string description: Description of the component + http_port: + title: Port + type: integer + default: 8080 + description: The port to run the GRPC service on diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.editorconfig b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.editorconfig new file mode 100644 index 0000000000..03eb226ef0 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +continuation_indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.gitignore b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.gitignore new file mode 100644 index 0000000000..1504daa184 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/.gitignore @@ -0,0 +1,7 @@ +target/ +## IntelliJ +.idea +*.iml + +## Temp +dependency-reduced-pom.xml diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/Dockerfile b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/Dockerfile new file mode 100644 index 0000000000..1c8a969c8c --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:11-alpine +ENTRYPOINT ["/usr/bin/{{cookiecutter.artifact_id}}.sh"] + +COPY {{cookiecutter.artifact_id}}.sh /usr/bin/{{cookiecutter.artifact_id}}.sh +COPY target/{{cookiecutter.artifact_id}}.jar /usr/share/{{cookiecutter.artifact_id}}/{{cookiecutter.artifact_id}}.jar diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/README.md b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/README.md new file mode 100644 index 0000000000..710b6b557e --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/README.md @@ -0,0 +1,3 @@ +# {{cookiecutter.component_id}} + +{{cookiecutter.description}} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/SERVICE.marker b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/SERVICE.marker new file mode 100644 index 0000000000..1dcf39a3b3 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/SERVICE.marker @@ -0,0 +1 @@ +(This marker file causes Maven to build this artifact as a service) diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml new file mode 100644 index 0000000000..f796df8820 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{cookiecutter.component_id}} + description: {{cookiecutter.description}} +spec: + type: service + lifecycle: experimental + owner: {{cookiecutter.owner}} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/pom.xml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/pom.xml new file mode 100644 index 0000000000..d5e81cf5e0 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/pom.xml @@ -0,0 +1,221 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.2.4.RELEASE + + + com.example + {{cookiecutter.artifact_id}} + 0.0.1-SNAPSHOT + jar + + {{cookiecutter.artifact_id}} + {{cookiecutter.description}} + + + 11 + ${java.version} + ${java.version} + ${java.version} + UTF-8 + UTF-8 + + + + jcenter + https://jcenter.bintray.com/ + + + + + io.github.lognet + grpc-spring-boot-starter + 3.5.1 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.apache.tomcat + annotations-api + 6.0.53 + provided + + + + + + ${project.artifactId} + + + kr.motd.maven + os-maven-plugin + 1.4.0.Final + + + + + + maven-compiler-plugin + 3.8.1 + + true + true + + -Xlint:all + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + + ${project.basedir}/src/main/resources + + com.google.protobuf:protoc:3.9.1:exe:${os.detected.classifier} + + grpc-java + + io.grpc:protoc-gen-grpc-java:1.13.1:exe:${os.detected.classifier} + + + + + + compile + compile-custom + + + + + + + + maven-failsafe-plugin + 2.22.2 + + + **/*IT.java + **/integrationtests/*.java + **/it/**/*.java + + + + + + + com.coveo + fmt-maven-plugin + 2.8 + + + + format + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.5 + + + + prepare-agent + report + + + + + + maven-enforcer-plugin + + + enforce + + enforce + + + + + + + + + + + + maven-jar-plugin + + + true + + ${start-class} + true + true + + + + ${project.version}-${buildTimestamp}-${buildNumber} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + build-info + + build-info + + + + + + maven-source-plugin + + + attach-sources + + jar + + + + + + maven-javadoc-plugin + + private + + + + attach-javadocs + + jar + + + + + + + diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/Application.java b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/Application.java new file mode 100644 index 0000000000..7ead2562d0 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/Application.java @@ -0,0 +1,12 @@ +package com.example.{{cookiecutter.java_package_name}}; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/GreeterService.java b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/GreeterService.java new file mode 100644 index 0000000000..90b8412a04 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/java/com/example/{{cookiecutter.java_package_name}}/GreeterService.java @@ -0,0 +1,19 @@ +package com.example.{{cookiecutter.java_package_name}}; + +import com.example.{{cookiecutter.java_package_name}}.proto.GreeterGrpc; +import com.example.{{cookiecutter.java_package_name}}.proto.GreeterOuterClass; +import io.grpc.stub.StreamObserver; +import org.lognet.springboot.grpc.GRpcService; + +@GRpcService +public class GreeterService extends GreeterGrpc.GreeterImplBase { + + @Override + public void sayHello(final GreeterOuterClass.HelloRequest request, + final StreamObserver responseObserver) { + final GreeterOuterClass.HelloReply.Builder replyBuilder = + GreeterOuterClass.HelloReply.newBuilder().setMessage("Hello " + request.getName()); + responseObserver.onNext(replyBuilder.build()); + responseObserver.onCompleted(); + } +} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/application.properties b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/application.properties new file mode 100644 index 0000000000..03be3b6ebf --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.application.name={{cookiecutter.component_id}} +server.port={{cookiecutter.http_port}} +grpc.enableReflection=true diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/banner.txt b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/banner.txt new file mode 100644 index 0000000000..46846ba35e --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/banner.txt @@ -0,0 +1,22 @@ +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_GREEN}8${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} +${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} ${AnsiColor.BRIGHT_WHITE} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/greeter.proto b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/greeter.proto new file mode 100644 index 0000000000..8015d914ee --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/main/resources/greeter.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "com.example.{{cookiecutter.java_package_name}}.proto"; + +service Greeter { + rpc SayHello ( HelloRequest) returns ( HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/test/java/com/example/{{cookiecutter.java_package_name}}/ApplicationTest.java b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/test/java/com/example/{{cookiecutter.java_package_name}}/ApplicationTest.java new file mode 100644 index 0000000000..1d7298172a --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/src/test/java/com/example/{{cookiecutter.java_package_name}}/ApplicationTest.java @@ -0,0 +1,15 @@ +package com.example.{{cookiecutter.java_package_name}}; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } +} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/{{cookiecutter.artifact_id}}.sh b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/{{cookiecutter.artifact_id}}.sh new file mode 100755 index 0000000000..c69b980dc8 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/{{cookiecutter.artifact_id}}.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/java $JVM_DEFAULT_ARGS $JVM_ARGS -jar /usr/share/{{cookiecutter.artifact_id}}/{{cookiecutter.artifact_id}}.jar "$@" diff --git a/plugins/scaffolder-backend/scripts/mock-data.sh b/plugins/scaffolder-backend/scripts/mock-data.sh index 2424d15094..68d011b977 100755 --- a/plugins/scaffolder-backend/scripts/mock-data.sh +++ b/plugins/scaffolder-backend/scripts/mock-data.sh @@ -2,7 +2,8 @@ for URL in \ 'react-ssr-template' \ - 'springboot-template' \ + 'springboot-grpc-template' \ + 'create-react-app' \ ; do \ curl \ --location \ diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts index 2e88dd34fe..a35be88832 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts @@ -23,7 +23,10 @@ export const makeLogStream = (meta: Record) => { // Create an empty stream to collect all the log lines into // one variable for the API. const stream = new PassThrough(); - stream.on('data', chunk => log.push(chunk.toString())); + stream.on('data', chunk => { + const textValue = chunk.toString().trim(); + if (textValue?.length > 1) log.push(textValue); + }); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index eba7def9af..5fd64d69fc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Publisher } from './types'; +import { PublisherBase } from './types'; import { Octokit } from '@octokit/rest'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; import { Repository, Remote, Signature, Cred } from 'nodegit'; -export class GithubPublisher implements Publisher { +export class GithubPublisher implements PublisherBase { private client: Octokit; constructor({ client }: { client: Octokit }) { this.client = client; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index 17b357a4ef..5d4e9dd521 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -21,7 +21,7 @@ import { JsonValue } from '@backstage/config'; * Publisher is in charge of taking a folder created by * the templater, and pushing it to a remote storage */ -export type Publisher = { +export type PublisherBase = { /** * * @param opts object containing the template entity from the service diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts new file mode 100644 index 0000000000..da33149c24 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts @@ -0,0 +1,80 @@ +/* + * 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 { runDockerContainer } from './helpers'; +import { TemplaterBase, TemplaterRunOptions } from '.'; +import path from 'path'; +import { TemplaterRunResult } from './types'; +import * as yaml from 'yaml'; + +export class CreateReactAppTemplater implements TemplaterBase { + public async run(options: TemplaterRunOptions): Promise { + const { + component_id: componentName, + use_typescript: withTypescript, + description, + owner, + } = options.values; + + const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + + await runDockerContainer({ + imageName: 'node:lts-alpine', + args: [ + 'create-react-app', + componentName as string, + withTypescript ? ' --template typescript' : '', + ], + templateDir: options.directory, + resultDir, + logStream: options.logStream, + dockerClient: options.dockerClient, + createOptions: { + Entrypoint: ['npx'], + WorkingDir: '/result', + }, + }); + + // Need to also make a component-info.yaml to store the data about the service. + const componentInfo = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: componentName, + description, + }, + spec: { + type: 'website', + lifecycle: 'experimental', + owner, + }, + }; + + const finalDir = path.resolve( + resultDir, + options.values.component_id as string, + ); + + await fs.promises.writeFile( + `${finalDir}/component-info.yaml`, + yaml.stringify(componentInfo), + ); + + return { + resultDir: finalDir, + }; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts index 02bc801385..e04ba48324 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts @@ -26,9 +26,15 @@ describe('helpers', () => { jest .spyOn(mockDocker, 'run') .mockResolvedValue([{ Error: null, StatusCode: 0 }]); - jest - .spyOn(mockDocker, 'pull') - .mockResolvedValue([{ Error: null, StatusCode: 0 }]); + jest.spyOn(mockDocker, 'pull').mockImplementation((async ( + _image: string, + _something: any, + handler: (err: Error | undefined, stream: PassThrough) => void, + ) => { + const mockStream = new PassThrough(); + handler(undefined, mockStream); + mockStream.end(); + }) as any); }); describe('runDockerContainer', () => { @@ -46,7 +52,11 @@ describe('helpers', () => { dockerClient: mockDocker, }); - expect(mockDocker.pull).toHaveBeenCalledWith(imageName, {}); + expect(mockDocker.pull).toHaveBeenCalledWith( + imageName, + {}, + expect.any(Function), + ); }); it('should call the dockerClient run command with the correct arguments passed through', async () => { await runDockerContainer({ diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index 0267d5eeab..11bbcd6005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -26,6 +26,7 @@ export type RunDockerContainerOptions = { resultDir: string; templateDir: string; dockerClient: Docker; + createOptions?: Docker.ContainerCreateOptions; }; /** @@ -59,8 +60,17 @@ export const runDockerContainer = async ({ resultDir, templateDir, dockerClient, + createOptions = {}, }: RunDockerContainerOptions) => { - await dockerClient.pull(imageName, {}); + await new Promise((resolve, reject) => { + dockerClient.pull(imageName, {}, (err, stream) => { + if (err) return reject(err); + stream.pipe(logStream, { end: false }); + stream.on('end', () => resolve()); + stream.on('error', (error: Error) => reject(error)); + return undefined; + }); + }); const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, @@ -75,6 +85,7 @@ export const runDockerContainer = async ({ `${await fs.promises.realpath(templateDir)}:/template`, ], }, + ...createOptions, }, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts index 56358f5c5b..45b787543a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts @@ -17,3 +17,4 @@ export * from './cookiecutter'; export * from './types'; export * from './helpers'; export * from './templaters'; +export * from './cra'; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 225662142f..fcfd5765a1 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -26,13 +26,13 @@ import { RequiredTemplateValues, StageContext, TemplaterBuilder, - Publisher, + PublisherBase, } from '../scaffolder'; export interface RouterOptions { preparers: PreparerBuilder; templaters: TemplaterBuilder; - publisher: Publisher; + publisher: PublisherBase; logger: Logger; dockerClient: Docker; @@ -55,18 +55,6 @@ export async function createRouter( const jobProcessor = new JobProcessor(); router - .get('/v1/job/:jobId/stage/:index/log', ({ params }, res) => { - const job = jobProcessor.get(params.jobId); - - if (!job) { - res.status(404).send({ error: 'job not found' }); - return; - } - - const { log } = job.stages[Number(params.index)] ?? { log: [] }; - - res.send(log.join('')); - }) .get('/v1/job/:jobId', ({ params }, res) => { const job = jobProcessor.get(params.jobId); @@ -126,7 +114,7 @@ export async function createRouter( { name: 'Publish template', handler: async (ctx: StageContext<{ resultDir: string }>) => { - ctx.logger.info('Should not store the template'); + ctx.logger.info('Will now store the template'); const { remoteUrl } = await publisher.publish({ entity: ctx.entity, values: ctx.values, diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1b6c9ff94a..e28387c30e 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.13", - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/plugin-catalog": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/catalog-model": "^0.1.1-alpha.16", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/plugin-catalog": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,13 +41,13 @@ "swr": "^0.2.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 71b73b752c..1e9adafb18 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -126,7 +126,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { ) : ( }>
- +
)} diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index ac13634842..12b3e303dd 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -23,17 +23,12 @@ import { Lifecycle, Page, pageTheme, + Progress, SupportButton, useApi, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; -import { - Button, - Grid, - LinearProgress, - Link, - Typography, -} from '@material-ui/core'; +import { Button, Grid, Typography, Link } from '@material-ui/core'; import React, { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import useStaleWhileRevalidate from 'swr'; @@ -69,7 +64,7 @@ export const ScaffolderPage: React.FC<{}> = () => { }, [error, errorApi]); return ( - +
= () => { documentation, ...). - - NOTE! This feature is WIP. You can follow progress{' '} - - here - - . - - {!templates && isValidating && } + {!templates && isValidating && } + {templates && !templates.length && ( + + Shoot! Looks like you don't have any templates. Check out the + documentation{' '} + + here! + + + )} + {error && ( + + Oops! Something went wrong loading the templates: {error.message} + + )} {templates && + templates?.length > 0 && templates.map(template => { return ( diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 2f36e12511..7d38c6f223 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button } from '@backstage/core'; +import { Button, pageTheme } from '@backstage/core'; import { Card, Chip, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; import { generatePath } from 'react-router-dom'; @@ -23,8 +23,8 @@ const useStyles = makeStyles(theme => ({ header: { color: theme.palette.common.white, padding: theme.spacing(2, 2, 6), - backgroundImage: - 'linear-gradient(-137deg, rgb(25, 230, 140) 0%, rgb(29, 127, 110) 100%)', + backgroundImage: (props: { gradientStart: string; gradientStop: string }) => + `linear-gradient(-137deg, ${props.gradientStart} 0%, ${props.gradientStop} 100%)`, }, content: { padding: theme.spacing(2), @@ -55,7 +55,9 @@ export const TemplateCard = ({ type, name, }: TemplateCardProps) => { - const classes = useStyles(); + const theme = pageTheme[type] ?? pageTheme.other; + const [gradientStart, gradientStop] = theme.gradient.colors; + const classes = useStyles({ gradientStart, gradientStop }); const href = generatePath(templateRoute.path, { templateName: name }); return ( @@ -72,7 +74,7 @@ export const TemplateCard = ({ {description}
-
diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 5c949e55d9..05cc249e41 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -22,6 +22,7 @@ import { Lifecycle, Page, useApi, + pageTheme, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; @@ -135,7 +136,7 @@ export const TemplatePage = () => { } return ( - +
{} }; +const ConfigApi = { getString: () => 'test' }; describe('SentryPluginPage', () => { it('should render header and time switched', () => { mockFetch.mockResponse('{}'); const rendered = render( - + diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx index ae5f8bd35c..0473cb805c 100644 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx +++ b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx @@ -30,6 +30,7 @@ import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab'; const SentryPluginPage: FC<{}> = () => { const [statsFor, setStatsFor] = useState<'12h' | '24h'>('12h'); const toggleStatsFor = () => setStatsFor(statsFor === '12h' ? '12h' : '24h'); + const sentryProjectId = 'sample-sentry-project-id'; return ( @@ -56,7 +57,7 @@ const SentryPluginPage: FC<{}> = () => { diff --git a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx b/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx index b101a9092a..8d522df4a7 100644 --- a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx +++ b/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx @@ -21,18 +21,21 @@ import { InfoCard, Progress, useApi, + configApiRef, } from '@backstage/core'; import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; import { useAsync } from 'react-use'; import { sentryApiFactory } from '../../data/api-factory'; -const api = sentryApiFactory('spotify'); - export const SentryPluginWidget: FC<{ sentryProjectId: string; statsFor: '24h' | '12h'; }> = ({ sentryProjectId, statsFor }) => { const errorApi = useApi(errorApiRef); + const configApi = useApi(configApiRef); + const org = configApi.getString('sentry.organization'); + const backendBaseUrl = configApi.getString('backend.baseUrl'); + const api = sentryApiFactory(org, backendBaseUrl); const { loading, value, error } = useAsync( () => api.fetchIssues(sentryProjectId, statsFor), diff --git a/plugins/sentry/src/data/api-factory.ts b/plugins/sentry/src/data/api-factory.ts index 32ff4bde5c..88e1148b72 100644 --- a/plugins/sentry/src/data/api-factory.ts +++ b/plugins/sentry/src/data/api-factory.ts @@ -17,9 +17,12 @@ import { SentryApi } from './sentry-api'; import { MockSentryApi } from './mock-api'; import { ProductionSentryApi } from './production-api'; -export function sentryApiFactory(organization: string): SentryApi { +export function sentryApiFactory( + organization: string, + backendBaseUrl: string, +): SentryApi { if (process.env.NODE_ENV === 'production') { - return new ProductionSentryApi(organization); + return new ProductionSentryApi(organization, backendBaseUrl); } return new MockSentryApi(); } diff --git a/plugins/sentry/src/data/production-api.ts b/plugins/sentry/src/data/production-api.ts index ce4bab5aca..5d21e80604 100644 --- a/plugins/sentry/src/data/production-api.ts +++ b/plugins/sentry/src/data/production-api.ts @@ -16,20 +16,21 @@ import { SentryIssue } from './sentry-issue'; import { SentryApi } from './sentry-api'; -const API_HOST = process.env.API_HOST || 'http://localhost:7000'; -const API_BASE_URL = `${API_HOST}/sentry/api/0/projects/`; - export class ProductionSentryApi implements SentryApi { private organization: string; + private backendBaseUrl: string; - constructor(organization: string) { + constructor(organization: string, backendBaseUrl: string) { this.organization = organization; + this.backendBaseUrl = backendBaseUrl; } async fetchIssues(project: string, statsFor: string): Promise { try { + const apiBaseUrl = `${this.backendBaseUrl}/sentry/api/0/projects/`; + const response = await fetch( - `${API_BASE_URL}/${this.organization}/${project}/issues/?statsFor=${statsFor}`, + `${apiBaseUrl}/${this.organization}/${project}/issues/?statsFor=${statsFor}`, ); if (response.status >= 400 && response.status < 600) { diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 1bdc66903b..0018a6faeb 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/test-utils-core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/test-utils-core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,14 +35,14 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/color": "^3.0.1", "@types/d3-force": "^1.2.1", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/techdocs-backend/examples/documented-component/docs/index.md b/plugins/techdocs-backend/examples/documented-component/docs/index.md new file mode 100644 index 0000000000..bf0895710f --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/index.md @@ -0,0 +1,3 @@ +# example docs + +This is a basic example of documentation. diff --git a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml new file mode 100644 index 0000000000..d5a1214b15 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: documented-component + description: A Service with TechDocs documentation + annotations: + backstage.io/techdocs-ref: 'dir:./' +spec: + type: service + lifecycle: experimental + owner: documented@example.com diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml new file mode 100644 index 0000000000..6a3f85d020 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: 'example-docs' + +nav: + - Home: index.md + +plugins: + - techdocs-core diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 0ccae9a6f7..edc8908cfc 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -17,10 +17,11 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" + "clean": "backstage-cli clean", + "mock-data": "./scripts/mock-data.sh" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.12", + "@backstage/backend-common": "^0.1.1-alpha.16", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -28,7 +29,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.12", + "@backstage/cli": "^0.1.1-alpha.16", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/scripts/mock-data.sh b/plugins/techdocs-backend/scripts/mock-data.sh new file mode 100755 index 0000000000..0630490694 --- /dev/null +++ b/plugins/techdocs-backend/scripts/mock-data.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +for URL in \ + 'documented-component/documented-component.yaml' \ +; do \ + curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/examples/${URL}\"}" + echo +done diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 4dcbeec668..210d297c7d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,9 +22,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/test-utils": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/test-utils": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,12 +36,12 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index a6b23bd454..8e11df428e 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -91,6 +91,10 @@ export const Reader = () => { return; // Page isn't ready } + if (state.value instanceof Error) { + return; // Docs not found + } + // Pre-render const transformedElement = transformer(state.value as string, [ sanitizeDOM(), diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 3f9763912c..f8f1e89b62 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -39,10 +39,9 @@ const documentationSites: Array = [ }, { title: 'Backstage Docs', - description: - 'Getting started guides, API Overview, documentation around how to Create a Plugin and more. ', + description: 'Main documentation for Backstage features and platform APIs.', tags: ['Service'], - path: '/docs/backstage-microsite', + path: '/docs/backstage', btnLabel: 'Read Docs', }, ]; diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index e826e899b0..66fec93779 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.13", + "version": "0.1.1-alpha.16", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -21,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.13", - "@backstage/theme": "^0.1.1-alpha.13", + "@backstage/core": "^0.1.1-alpha.16", + "@backstage/theme": "^0.1.1-alpha.16", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,12 +32,12 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.13", - "@backstage/dev-utils": "^0.1.1-alpha.13", + "@backstage/cli": "^0.1.1-alpha.16", + "@backstage/dev-utils": "^0.1.1-alpha.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/yarn.lock b/yarn.lock index db28fa3c57..cb354e71d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,51 @@ # yarn lockfile v1 +"@apollo/protobufjs@^1.0.3": + version "1.0.4" + resolved "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.0.4.tgz#cf01747a55359066341f31b5ce8db17df44244e0" + integrity sha512-EE3zx+/D/wur/JiLp6VCiw1iYdyy1lCJMf8CGPkLeDt5QJrN4N8tKFx33Ah4V30AUQzMk7Uz4IXKZ1LOj124gA== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/long" "^4.0.0" + "@types/node" "^10.1.0" + long "^4.0.0" + +"@apollographql/apollo-tools@^0.4.3": + version "0.4.8" + resolved "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.4.8.tgz#d81da89ee880c2345eb86bddb92b35291f6135ed" + integrity sha512-W2+HB8Y7ifowcf3YyPHgDI05izyRtOeZ4MqIr7LbTArtmJ0ZHULWpn84SGMW7NAvTV1tFExpHlveHhnXuJfuGA== + dependencies: + apollo-env "^0.6.5" + +"@apollographql/graphql-playground-html@1.6.26": + version "1.6.26" + resolved "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.26.tgz#2f7b610392e2a872722912fc342b43cf8d641cb3" + integrity sha512-XAwXOIab51QyhBxnxySdK3nuMEUohhDsHQ5Rbco/V1vjlP75zZ0ZLHD9dTpXTN8uxKxopb2lUvJTq+M4g2Q0HQ== + dependencies: + xss "^1.0.6" + +"@ardatan/aggregate-error@0.0.1": + version "0.0.1" + resolved "https://registry.npmjs.org/@ardatan/aggregate-error/-/aggregate-error-0.0.1.tgz#1403ac5de10d8ca689fc1f65844c27179ae1d44f" + integrity sha512-UQ9BequOTIavs0pTHLMwQwKQF8tTV1oezY/H2O9chA+JNPFZSua55xpU5dPSjAU9/jLJ1VwU+HJuTVN8u7S6Fg== + +"@babel/code-frame@7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== + dependencies: + "@babel/highlight" "^7.0.0" + "@babel/code-frame@7.5.5": version "7.5.5" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" @@ -899,6 +944,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.10.0": + version "7.10.5" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz#303d8bd440ecd5a491eae6117fd3367698674c5c" + integrity sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -1177,6 +1229,109 @@ unique-filename "^1.1.1" which "^1.3.1" +"@graphql-tools/delegate@6.0.15": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-6.0.15.tgz#9e060bfc31fe7735bd5b2b401e98dea3fa5d3b25" + integrity sha512-GG/zp29PMfG6eXpfe1M5C3U1EI1f3tJu2glFN8t0RIfp4FEgZs/PRvZuuep5orFge8dvX/LQpJY8Vl2JmU4WMg== + dependencies: + "@ardatan/aggregate-error" "0.0.1" + "@graphql-tools/schema" "6.0.15" + "@graphql-tools/utils" "6.0.15" + tslib "~2.0.0" + +"@graphql-tools/graphql-file-loader@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-6.0.15.tgz#e186b8147397bd7510e1b3318f5d3f7d365fc4e1" + integrity sha512-QbCf731A2A2hrHP+cMSAKvY3D7IauFNqp5bAGdbLwSHRqaxUIfKi7Q76/9pZ3rN/e6yu/zVz+t1rkf7lT2/8OA== + dependencies: + "@graphql-tools/import" "6.0.15" + "@graphql-tools/utils" "6.0.15" + fs-extra "9.0.1" + tslib "~2.0.0" + +"@graphql-tools/import@6.0.15": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/import/-/import-6.0.15.tgz#d6a9d3b6199a1e07d80d8021635d317b2c190c6f" + integrity sha512-YaQizD031nlrObiAJj+DO+0Wf2ompR2G5OFNQZIOgUlm1+kfH3GPIFoE5Ww74YH6vy9s4UyYYeZJz6APxPdMzg== + dependencies: + fs-extra "9.0.1" + resolve-from "5.0.0" + +"@graphql-tools/json-file-loader@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-6.0.15.tgz#e341a5fed1ef3af8f8bfde17ed9a14e1a6df36c0" + integrity sha512-SQO7w+KPxW6Q3snE3G4eNOA8CcBBDYHpk8JILj93oe4BassuPY5NCUOeZ+2PYczwZQbTNDQXeW1oQou44U1aBg== + dependencies: + "@graphql-tools/utils" "6.0.15" + fs-extra "9.0.1" + tslib "~2.0.0" + +"@graphql-tools/load@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/load/-/load-6.0.15.tgz#f2012b8938b3a535dbafefbb719997684028f30f" + integrity sha512-STH3ZjbViRqDyCw+f7PZrnDs6yhP7m2l4x5lJBMyMeLaLwuO1z+WhgtqYZNpCYlQY2jNSLXWCa0nWmpYvdLnlA== + dependencies: + "@graphql-tools/merge" "6.0.15" + "@graphql-tools/utils" "6.0.15" + globby "11.0.1" + import-from "3.0.0" + is-glob "4.0.1" + p-limit "3.0.2" + tslib "~2.0.0" + unixify "1.0.0" + valid-url "1.0.9" + +"@graphql-tools/merge@6.0.15", "@graphql-tools/merge@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/merge/-/merge-6.0.15.tgz#09c84bd08971edfb3e1035d71432b47687820bdc" + integrity sha512-qusTLzkf6GtxS6LRQnEAWIwA1BeJj5SkZ2pnE4/wVe9gs0grpEsOKYxvGpBi8IZR7r8UeNpkdgk2HP0jlq/WWA== + dependencies: + "@graphql-tools/schema" "6.0.15" + "@graphql-tools/utils" "6.0.15" + tslib "~2.0.0" + +"@graphql-tools/schema@6.0.15": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-6.0.15.tgz#b016f9f36820342982887a291baa7e7d11b039ae" + integrity sha512-Wo+d6/OPjeXjwB1pcqsWmqLdweGH+BVhvKe/YPQA/uiWr8ikgShvNLNiuF03gc/1AMR487A09XcPEyabRKJLew== + dependencies: + "@graphql-tools/utils" "6.0.15" + tslib "~2.0.0" + +"@graphql-tools/url-loader@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-6.0.15.tgz#3ad621ed75c03fbc0ea08d2560f379a053c4035f" + integrity sha512-/iGuK7J9yCECYMYQJqKNWnz4ytPHppkxh4YS5Ud9QPDNl488e+eInyNbkdiWcFGyZ4KHqEnXSDdRFg3mFNrMnw== + dependencies: + "@graphql-tools/delegate" "6.0.15" + "@graphql-tools/utils" "6.0.15" + "@graphql-tools/wrap" "6.0.15" + "@types/websocket" "1.0.1" + cross-fetch "3.0.5" + subscriptions-transport-ws "0.9.17" + tslib "~2.0.0" + valid-url "1.0.9" + websocket "1.0.31" + +"@graphql-tools/utils@6.0.15", "@graphql-tools/utils@^6.0.0": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.15.tgz#6d54d383285bea3c22797531933b62a408e78e49" + integrity sha512-VG5cMLPgh9RDLGHamGpXVnBrNw7bZGT46LrxK7IIqDZI9H0GPsRCo8+p+CfDkw0IlDiEECb624WVCpm9IYNecA== + dependencies: + "@ardatan/aggregate-error" "0.0.1" + camel-case "4.1.1" + +"@graphql-tools/wrap@6.0.15": + version "6.0.15" + resolved "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-6.0.15.tgz#1b4a43d406f3894e4cbc931f9d4e0d282ab0076e" + integrity sha512-yWiDBrbzml6PRl4aeJBLNGPw385LFtszMfkfYwjLSWvNyVILDCMa/XWHThw4FMaZ1nPL0GuLggW2bVkUBi3TYA== + dependencies: + "@graphql-tools/delegate" "6.0.15" + "@graphql-tools/schema" "6.0.15" + "@graphql-tools/utils" "6.0.15" + aggregate-error "3.0.1" + tslib "~2.0.0" + "@hapi/address@^2.1.2": version "2.1.4" resolved "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -2437,6 +2592,59 @@ resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6" integrity sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw== +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78= + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A= + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU= + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E= + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik= + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0= + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q= + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= + "@reach/router@^1.2.1": version "1.3.3" resolved "https://registry.npmjs.org/@reach/router/-/router-1.3.3.tgz#58162860dce6c9449d49be86b0561b5ef46d80db" @@ -2502,7 +2710,16 @@ is-module "^1.0.0" resolve "^1.14.2" -"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": +"@rollup/plugin-yaml@^2.1.1": + version "2.1.1" + resolved "https://registry.npmjs.org/@rollup/plugin-yaml/-/plugin-yaml-2.1.1.tgz#6c864adee22004eb325d53bc4ee5553828e056d8" + integrity sha512-CnGD3dbDhP+JeKFX7kJuaBOa7jVzXpl3G8lXp1hasB87cDYOz7qNWd4cFPmYbxMsT/8OCVsceIpRtQ+cj9uxBw== + dependencies: + "@rollup/pluginutils" "^3.0.1" + js-yaml "^3.13.1" + tosource "^1.0.0" + +"@rollup/pluginutils@^3.0.1", "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== @@ -3326,6 +3543,13 @@ "@theme-ui/core" "^0.3.1" "@theme-ui/mdx" "^0.3.0" +"@types/accepts@*", "@types/accepts@^1.3.5": + version "1.3.5" + resolved "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" + integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ== + dependencies: + "@types/node" "*" + "@types/anymatch@*": version "1.3.1" resolved "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" @@ -3364,7 +3588,7 @@ dependencies: "@babel/types" "^7.3.0" -"@types/body-parser@*", "@types/body-parser@^1.19.0": +"@types/body-parser@*", "@types/body-parser@1.19.0", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== @@ -3432,6 +3656,11 @@ dependencies: "@types/node" "*" +"@types/content-disposition@*": + version "0.5.3" + resolved "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96" + integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg== + "@types/cookie-parser@^1.4.2": version "1.4.2" resolved "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5" @@ -3449,7 +3678,17 @@ resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" integrity sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw== -"@types/cors@^2.8.6": +"@types/cookies@*": + version "0.7.4" + resolved "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b" + integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + +"@types/cors@^2.8.4", "@types/cors@^2.8.6": version "2.8.6" resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz#cfaab33c49c15b1ded32f235111ce9123009bd02" integrity sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg== @@ -3509,23 +3748,41 @@ integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.5" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz#a00ac7dadd746ae82477443e4d480a6a93ea083c" - integrity sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw== + version "4.17.9" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" + integrity sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA== dependencies: "@types/node" "*" + "@types/qs" "*" "@types/range-parser" "*" "@types/express@*", "@types/express@^4.17.6": - version "4.17.6" - resolved "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e" - integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w== + version "4.17.7" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" + integrity sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "*" "@types/qs" "*" "@types/serve-static" "*" +"@types/express@4.17.4": + version "4.17.4" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.4.tgz#e78bf09f3f530889575f4da8a94cd45384520aac" + integrity sha512-DO1L53rGqIDUEvOjJKmbMEQ5Z+BM2cIEPy/eV3En+s166Gz+FeuzRerxcab757u/U4v4XF4RYrZPmqKa+aY/2w== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "*" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/fs-capacitor@*": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz#17113e25817f584f58100fb7a08eed288b81956e" + integrity sha512-FKVPOCFbhCvZxpVAMhdBdTfVfXUpsh15wFHgqOKxh9N9vzWZVuWCSijZ5T4U34XYNnuj2oduh6xcs1i+LPI+BQ== + dependencies: + "@types/node" "*" + "@types/fs-extra@^9.0.1": version "9.0.1" resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.1.tgz#91c8fc4c51f6d5dbe44c2ca9ab09310bd00c7918" @@ -3538,6 +3795,11 @@ resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== +"@types/github-slugger@^1.3.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz#16ab393b30d8ae2a111ac748a015ac05a1fc5524" + integrity sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g== + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -3559,6 +3821,16 @@ dependencies: "@types/node" "*" +"@types/graphql-upload@^8.0.0": + version "8.0.3" + resolved "https://registry.npmjs.org/@types/graphql-upload/-/graphql-upload-8.0.3.tgz#b371edb5f305a2a1f7b7843a890a2a7adc55c3ec" + integrity sha512-hmLg9pCU/GmxBscg8GCr1vmSoEmbItNNxdD5YH2TJkXm//8atjwuprB+xJBK714JG1dkxbbhp5RHX+Pz1KsCMA== + dependencies: + "@types/express" "*" + "@types/fs-capacitor" "*" + "@types/koa" "*" + graphql "^14.5.3" + "@types/helmet@^0.0.47": version "0.0.47" resolved "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.47.tgz#ec5161541b649142205b7c558bca14801c5ce129" @@ -3594,6 +3866,11 @@ "@types/tapable" "*" "@types/webpack" "*" +"@types/http-assert@*": + version "1.5.1" + resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" + integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ== + "@types/http-errors@^1.6.3": version "1.6.3" resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.6.3.tgz#619a55768eab98299e8f76747339f3373f134e69" @@ -3648,10 +3925,10 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/jest@*", "@types/jest@^25.2.2": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" - integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== +"@types/jest@*", "@types/jest@^26.0.7": + version "26.0.7" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.7.tgz#495cb1d1818c1699dbc3b8b046baf1c86ef5e324" + integrity sha512-+x0077/LoN6MjqBcVOe1y9dpryWnfDZ+Xfo3EqGeBcfPRJlQp3Lw62RvNlWxuGv7kOEwlHriAa54updi3Jvvwg== dependencies: jest-diff "^25.2.1" pretty-format "^25.2.1" @@ -3688,11 +3965,41 @@ resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A== +"@types/keygrip@*": + version "1.0.2" + resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" + integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== + +"@types/koa-compose@*": + version "3.2.5" + resolved "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" + integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ== + dependencies: + "@types/koa" "*" + +"@types/koa@*": + version "2.11.3" + resolved "https://registry.npmjs.org/@types/koa/-/koa-2.11.3.tgz#540ece376581b12beadf9a417dd1731bc31c16ce" + integrity sha512-ABxVkrNWa4O/Jp24EYI/hRNqEVRlhB9g09p48neQp4m3xL1TJtdWk2NyNQSMCU45ejeELMQZBYyfstyVvO2H3Q== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + "@types/lodash@^4.14.151": version "4.14.155" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" integrity sha512-vEcX7S7aPhsBCivxMwAANQburHBtfN9RdyXFk84IJmu2Z4Hkg1tOFgaslRiEqqvoLtbCBi6ika1EMspE+NZ9Lg== +"@types/long@^4.0.0": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" + integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== + "@types/mime@*": version "2.0.1" resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" @@ -3724,7 +4031,7 @@ dependencies: "@types/node" "*" -"@types/node-fetch@^2.5.7": +"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.7": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== @@ -3732,15 +4039,25 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@^13.7.2": - version "13.9.2" - resolved "https://registry.npmjs.org/@types/node/-/node-13.9.2.tgz#ace1880c03594cc3e80206d96847157d8e7fa349" - integrity sha512-bnoqK579sAYrQbp73wwglccjJ4sfRdKU7WNEZ5FW4K2U6Kc0/eZ5kvXG0JKsEKFB50zrFmfFt52/cvBbZa7eXg== +"@types/node@*", "@types/node@>= 8": + version "14.0.26" + resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" + integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA== + +"@types/node@^10.1.0": + version "10.17.28" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9" + integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ== "@types/node@^12.0.0": - version "12.12.30" - resolved "https://registry.npmjs.org/@types/node/-/node-12.12.30.tgz#3501e6f09b954de9c404671cefdbcc5d9d7c45f6" - integrity sha512-sz9MF/zk6qVr3pAnM0BSQvYIBK44tS75QC5N+VbWSE4DjCV/pJ+UzCW/F+vVnl7TkOPcuwQureKNtSSwjBTaMg== + version "12.12.53" + resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129" + integrity sha512-51MYTDTyCziHb70wtGNFRwB4l+5JNvdqzFSkbDvpbftEgVUBEE+T5f7pROhWMp/fxp07oNIEQZd5bbfAH22ohQ== + +"@types/node@^13.7.2": + version "13.13.15" + resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" + integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== "@types/nodegit@0.26.5": version "0.26.5" @@ -3836,9 +4153,9 @@ integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== "@types/qs@*": - version "6.9.1" - resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz#937fab3194766256ee09fcd40b781740758617e7" - integrity sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw== + version "6.9.4" + resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a" + integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ== "@types/range-parser@*": version "1.2.3" @@ -4116,12 +4433,7 @@ "@types/serve-static" "*" "@types/webpack" "*" -"@types/webpack-env@^1.15.0": - version "1.15.1" - resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.1.tgz#c8e84705e08eed430b5e15b39c65b0944e4d1422" - integrity sha512-eWN5ElDTeBc5lRDh95SqA8x18D0ll2pWudU3uWiyfsRmIZcmUXpEsxPU+7+BsdCrO2vfLRC629u/MmjbmF+2tA== - -"@types/webpack-env@^1.15.2": +"@types/webpack-env@^1.15.0", "@types/webpack-env@^1.15.2": version "1.15.2" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.2.tgz#927997342bb9f4a5185a86e6579a0a18afc33b0a" integrity sha512-67ZgZpAlhIICIdfQrB5fnDvaKFcDxpKibxznfYRVAT4mQE41Dido/3Ty+E3xGBmTogc5+0Qb8tWhna+5B8z1iQ== @@ -4154,11 +4466,25 @@ "@types/webpack-sources" "*" source-map "^0.6.0" +"@types/websocket@1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.1.tgz#039272c196c2c0e4868a0d8a1a27bbb86e9e9138" + integrity sha512-f5WLMpezwVxCLm1xQe/kdPpQIOmL0TXYx2O15VYfYzc7hTIdxiOoOvez+McSIw3b7z/1zGovew9YSL7+h4h7/Q== + dependencies: + "@types/node" "*" + "@types/whatwg-streams@^0.0.7": version "0.0.7" resolved "https://registry.npmjs.org/@types/whatwg-streams/-/whatwg-streams-0.0.7.tgz#28bfe73dc850562296367249c4b32a50db81e9d3" integrity sha512-6sDiSEP6DWcY2ZolsJ2s39ZmsoGQ7KVwBDI3sESQsEm9P2dHTcqnDIHRZFRNtLCzWp7hCFGqYbw5GyfpQnJ01A== +"@types/ws@^7.0.0": + version "7.2.6" + resolved "https://registry.npmjs.org/@types/ws/-/ws-7.2.6.tgz#516cbfb818310f87b43940460e065eb912a4178d" + integrity sha512-Q07IrQUSNpr+cXU4E4LtkSIBPie5GLZyyMC1QtQYRLWz701+XcoVygGUZgvLqElq1nU4ICldMYPnexlBsg3dqQ== + dependencies: + "@types/node" "*" + "@types/yaml@^1.9.7": version "1.9.7" resolved "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz#2331f36e0aac91311a63d33eb026c21687729679" @@ -4383,6 +4709,13 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" +"@wry/equality@^0.1.2": + version "0.1.11" + resolved "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" + integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA== + dependencies: + tslib "^1.9.3" + "@xobotyi/scrollbar-width@1.9.5": version "1.9.5" resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" @@ -4425,7 +4758,7 @@ abbrev@1: resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== -accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: +accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== @@ -4510,7 +4843,7 @@ agentkeepalive@^3.4.1: dependencies: humanize-ms "^1.2.1" -aggregate-error@^3.0.0: +aggregate-error@3.0.1, aggregate-error@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" integrity sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA== @@ -4668,6 +5001,187 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +apollo-cache-control@^0.11.1: + version "0.11.1" + resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.1.tgz#3bce0924ae7322a8b9f7ca1e2fb036d1fc9f1df5" + integrity sha512-6iHa8TkcKt4rx5SKRzDNjUIpCQX+7/FlZwD7vRh9JDnM4VH8SWhpj8fUR3CiEY8Kuc4ChXnOY8bCcMju5KPnIQ== + dependencies: + apollo-server-env "^2.4.5" + apollo-server-plugin-base "^0.9.1" + +apollo-datasource@^0.7.2: + version "0.7.2" + resolved "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.2.tgz#1662ee93453a9b89af6f73ce561bde46b41ebf31" + integrity sha512-ibnW+s4BMp4K2AgzLEtvzkjg7dJgCaw9M5b5N0YKNmeRZRnl/I/qBTQae648FsRKgMwTbRQIvBhQ0URUFAqFOw== + dependencies: + apollo-server-caching "^0.5.2" + apollo-server-env "^2.4.5" + +apollo-engine-reporting-protobuf@^0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz#b01812508a1c583328a8dc603769bc63b8895e7e" + integrity sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg== + dependencies: + "@apollo/protobufjs" "^1.0.3" + +apollo-engine-reporting@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.3.0.tgz#3bb59f81aaf6b967ed098896a4a60a053b4eed5a" + integrity sha512-SbcPLFuUZcRqDEZ6mSs8uHM9Ftr8yyt2IEu0JA8c3LNBmYXSLM7MHqFe80SVcosYSTBgtMz8mLJO8orhYoSYZw== + dependencies: + apollo-engine-reporting-protobuf "^0.5.2" + apollo-graphql "^0.5.0" + apollo-server-caching "^0.5.2" + apollo-server-env "^2.4.5" + apollo-server-errors "^2.4.2" + apollo-server-plugin-base "^0.9.1" + apollo-server-types "^0.5.1" + async-retry "^1.2.1" + uuid "^8.0.0" + +apollo-env@^0.6.5: + version "0.6.5" + resolved "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.5.tgz#5a36e699d39e2356381f7203493187260fded9f3" + integrity sha512-jeBUVsGymeTHYWp3me0R2CZRZrFeuSZeICZHCeRflHTfnQtlmbSXdy5E0pOyRM9CU4JfQkKDC98S1YglQj7Bzg== + dependencies: + "@types/node-fetch" "2.5.7" + core-js "^3.0.1" + node-fetch "^2.2.0" + sha.js "^2.4.11" + +apollo-graphql@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.5.0.tgz#7e9152093211b58352aa6504d8d39ec7241d6872" + integrity sha512-YSdF/BKPbsnQpxWpmCE53pBJX44aaoif31Y22I/qKpB6ZSGzYijV5YBoCL5Q15H2oA/v/02Oazh9lbp4ek3eig== + dependencies: + apollo-env "^0.6.5" + lodash.sortby "^4.7.0" + +apollo-link@^1.2.14: + version "1.2.14" + resolved "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9" + integrity sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg== + dependencies: + apollo-utilities "^1.3.0" + ts-invariant "^0.4.0" + tslib "^1.9.3" + zen-observable-ts "^0.8.21" + +apollo-server-caching@^0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz#bef5d5e0d48473a454927a66b7bb947a0b6eb13e" + integrity sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ== + dependencies: + lru-cache "^5.0.0" + +apollo-server-core@^2.16.0: + version "2.16.0" + resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.16.0.tgz#56b367db49f97b7da03b29cef89b63d9ed14ee0c" + integrity sha512-mnvg2cPvsQtjFXIqIhEAbPqGyiSXDSbiBgNQ8rY8g7r2eRMhHKZePqGF03gP1/w87yVaSDRAZBDk6o+jiBXjVQ== + dependencies: + "@apollographql/apollo-tools" "^0.4.3" + "@apollographql/graphql-playground-html" "1.6.26" + "@types/graphql-upload" "^8.0.0" + "@types/ws" "^7.0.0" + apollo-cache-control "^0.11.1" + apollo-datasource "^0.7.2" + apollo-engine-reporting "^2.3.0" + apollo-server-caching "^0.5.2" + apollo-server-env "^2.4.5" + apollo-server-errors "^2.4.2" + apollo-server-plugin-base "^0.9.1" + apollo-server-types "^0.5.1" + apollo-tracing "^0.11.1" + fast-json-stable-stringify "^2.0.0" + graphql-extensions "^0.12.4" + graphql-tag "^2.9.2" + graphql-tools "^4.0.0" + graphql-upload "^8.0.2" + loglevel "^1.6.7" + sha.js "^2.4.11" + subscriptions-transport-ws "^0.9.11" + ws "^6.0.0" + +apollo-server-env@^2.4.5: + version "2.4.5" + resolved "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz#73730b4f0439094a2272a9d0caa4079d4b661d5f" + integrity sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA== + dependencies: + node-fetch "^2.1.2" + util.promisify "^1.0.0" + +apollo-server-errors@^2.4.2: + version "2.4.2" + resolved "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.2.tgz#1128738a1d14da989f58420896d70524784eabe5" + integrity sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ== + +apollo-server-express@^2.16.0: + version "2.16.0" + resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.16.0.tgz#3474a7f7eb868a2a847a364839147f8d7f26454a" + integrity sha512-mBIvKcF8gApj7wbmqe0A4Tsy+Pw66mI6cmtD912bG59KhUBveSCZ21dDlRSvnXUyK+GOo2ItwcUEtmks+Z2Pqw== + dependencies: + "@apollographql/graphql-playground-html" "1.6.26" + "@types/accepts" "^1.3.5" + "@types/body-parser" "1.19.0" + "@types/cors" "^2.8.4" + "@types/express" "4.17.4" + accepts "^1.3.5" + apollo-server-core "^2.16.0" + apollo-server-types "^0.5.1" + body-parser "^1.18.3" + cors "^2.8.4" + express "^4.17.1" + graphql-subscriptions "^1.0.0" + graphql-tools "^4.0.0" + parseurl "^1.3.2" + subscriptions-transport-ws "^0.9.16" + type-is "^1.6.16" + +apollo-server-plugin-base@^0.9.1: + version "0.9.1" + resolved "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.9.1.tgz#a62ae9ab4e89790fd4cc5d123bb616da34e8e5fb" + integrity sha512-kvrX4Z3FdpjrZdHkyl5iY2A1Wvp4b6KQp00DeZqss7GyyKNUBKr80/7RQgBLEw7EWM7WB19j459xM/TjvW0FKQ== + dependencies: + apollo-server-types "^0.5.1" + +apollo-server-types@^0.5.1: + version "0.5.1" + resolved "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz#091c09652894d6532db9ba873574443adabf85b9" + integrity sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA== + dependencies: + apollo-engine-reporting-protobuf "^0.5.2" + apollo-server-caching "^0.5.2" + apollo-server-env "^2.4.5" + +apollo-server@^2.16.0: + version "2.16.0" + resolved "https://registry.npmjs.org/apollo-server/-/apollo-server-2.16.0.tgz#fa30e29b78e8cb70b2c81d0f7b96953beb3d4baf" + integrity sha512-zbEe0FSqatqE6bmIfq/o1/Hsqc596ZOwZM/L8Ttwa4ucQ1ybqf1ZejSYu6ehFEj1G6rOBY1ttVKkIllcErK4GQ== + dependencies: + apollo-server-core "^2.16.0" + apollo-server-express "^2.16.0" + express "^4.0.0" + graphql-subscriptions "^1.0.0" + graphql-tools "^4.0.0" + +apollo-tracing@^0.11.1: + version "0.11.1" + resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.1.tgz#3e3a4ce4b21e57dcc57b10bbd539db243b752606" + integrity sha512-l7g+uILw7v32GA46IRXIx5XXbZhFI96BhSqrGK9yyvfq+NMcvVZrj3kIhRImPGhAjMdV+5biA/jztabElAbDjg== + dependencies: + apollo-server-env "^2.4.5" + apollo-server-plugin-base "^0.9.1" + +apollo-utilities@^1.0.1, apollo-utilities@^1.3.0: + version "1.3.4" + resolved "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz#6129e438e8be201b6c55b0f13ce49d2c7175c9cf" + integrity sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig== + dependencies: + "@wry/equality" "^0.1.2" + fast-json-stable-stringify "^2.0.0" + ts-invariant "^0.4.0" + tslib "^1.10.0" + app-root-dir@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" @@ -4916,6 +5430,13 @@ async-limiter@~1.0.0: resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-retry@^1.2.1: + version "1.3.1" + resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz#139f31f8ddce50c0870b0ba558a6079684aaed55" + integrity sha512-aiieFW/7h3hY0Bq5d+ktDBejxuwR78vRu9hDUdR8rNhSaQ29VzPL4AoIRG7D/c7tdenwOcKvgPM6tIxB3cB6HA== + dependencies: + retry "0.12.0" + async@^2.6.1, async@^2.6.2: version "2.6.3" resolved "https://registry.npmjs.org/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -5313,6 +5834,11 @@ babel-runtime@6.26.0, babel-runtime@^6.26.0: core-js "^2.4.0" regenerator-runtime "^0.11.0" +backo2@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" + integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= + bail@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" @@ -5434,7 +5960,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -body-parser@1.19.0, body-parser@^1.19.0: +body-parser@1.19.0, body-parser@^1.18.3, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -5662,6 +6188,16 @@ buffer-indexof@^1.0.0: resolved "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== +buffer-writer@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz#22a936901e3029afcd7547eb4487ceb697a3bf08" + integrity sha1-Iqk2kB4wKa/NdUfrRIfOtpejvwg= + +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -5699,6 +6235,13 @@ builtins@^1.0.3: resolved "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= +busboy@^0.3.1: + version "0.3.1" + resolved "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" + integrity sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw== + dependencies: + dicer "0.3.0" + byline@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" @@ -5826,7 +6369,7 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camel-case@^4.1.1: +camel-case@4.1.1, camel-case@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== @@ -5865,6 +6408,11 @@ camelcase@^2.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= +camelcase@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" + integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= + camelcase@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -5950,7 +6498,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.1.0: +chalk@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== @@ -6162,6 +6710,15 @@ clipboard@^2.0.0: select "^1.1.2" tiny-emitter "^2.0.0" +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + cliui@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -6363,7 +6920,7 @@ commander@4.1.1, commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@~2.20.3: +commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -6722,7 +7279,7 @@ corejs-upgrade-webpack-plugin@^2.2.0: resolve-from "^5.0.0" webpack "^4.38.0" -cors@^2.8.5: +cors@^2.8.4, cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -6730,6 +7287,17 @@ cors@^2.8.5: object-assign "^4" vary "^1" +cosmiconfig@6.0.0, cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" @@ -6740,17 +7308,6 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: js-yaml "^3.13.1" parse-json "^4.0.0" -cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" - create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -6797,6 +7354,13 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" +cross-fetch@3.0.5: + version "3.0.5" + resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.5.tgz#2739d2981892e7ab488a7ad03b92df2816e03f4c" + integrity sha512-FFLcLtraisj5eteosnX1gf01qYDCOc4fDy0+euOt8Kn9YBY2NtXL/pCoYPavw24NIQkQqm5ZOLsGD5Zzj0gyew== + dependencies: + node-fetch "2.6.0" + cross-fetch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c" @@ -7003,6 +7567,11 @@ cssesc@^3.0.0: resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfilter@0.0.10: + version "0.0.10" + resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" + integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4= + cssnano-preset-default@^4.0.7: version "4.0.7" resolved "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" @@ -7186,6 +7755,14 @@ d3-timer@1: resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== +d@1, d@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" + integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + dependencies: + es5-ext "^0.10.50" + type "^1.0.1" + damerau-levenshtein@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" @@ -7294,7 +7871,7 @@ decamelize-keys@^1.0.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0: +decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -7459,6 +8036,11 @@ depd@~1.1.2: resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +deprecated-decorator@^0.1.6: + version "0.1.6" + resolved "https://registry.npmjs.org/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" + integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= + deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" @@ -7535,6 +8117,13 @@ diagnostics@^1.1.1: enabled "1.0.x" kuler "1.0.x" +dicer@0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz#eacd98b3bfbf92e8ab5c2fdb71aaac44bb06b872" + integrity sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA== + dependencies: + streamsearch "0.1.2" + diff-sequences@^25.2.6: version "25.2.6" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" @@ -7882,6 +8471,11 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +"emoji-regex@>=6.0.0 <=6.1.1": + version "6.1.1" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" + integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= + emoji-regex@^7.0.1, emoji-regex@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -8048,11 +8642,29 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es5-ext@^0.10.35, es5-ext@^0.10.50: + version "0.10.53" + resolved "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" + integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.3" + next-tick "~1.0.0" + es5-shim@^4.5.13: version "4.5.13" resolved "https://registry.npmjs.org/es5-shim/-/es5-shim-4.5.13.tgz#5d88062de049f8969f83783f4a4884395f21d28b" integrity sha512-xi6hh6gsvDE0MaW4Vp1lgNEBpVcCXRWfPXj5egDvtgLz4L9MEvNwYEMdJH+JJinWkwa8c3c3o5HduV7dB/e1Hw== +es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" @@ -8070,10 +8682,18 @@ es6-shim@^0.35.5: resolved "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.5.tgz#46f59dc0a84a1c5029e8ff1166ca0a902077a9ab" integrity sha512-E9kK/bjtCQRpN1K28Xh4BlmP8egvZBGJJ+9GtnzOwt7mdqtrjHFuVGr7QJfdjBIKqrlU5duPf3pCBoDrkjVYFg== -esbuild@^0.5.3: - version "0.5.3" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.5.3.tgz#18f5bdb618220c6f14bcb1cf5af528d02d4734c9" - integrity sha512-RVzTK62svYjnh+agJRh+NWfZX74iKwFNUX52cF7Mo4QPS6bKxP1o+8GacPUMND2QnodVp2D3nKJs8gLspSfZzA== +es6-symbol@^3.1.1, es6-symbol@~3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" + integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== + dependencies: + d "^1.0.1" + ext "^1.1.2" + +esbuild@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.3.tgz#a957e22f2503c745793514388110f9b55b3a6f83" + integrity sha512-4lHgz/EvGLRQnDYzzrvW+eilaPHim5pCLz4mP0k0QIalD/n8Ji2NwQwoIa1uX+yKkbn9R/FAZvaEbodjx55rDg== escape-goat@^2.0.0: version "2.1.1" @@ -8114,6 +8734,17 @@ eslint-config-prettier@^6.0.0: dependencies: get-stdin "^6.0.0" +eslint-formatter-friendly@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/eslint-formatter-friendly/-/eslint-formatter-friendly-7.0.0.tgz#32a4998ababa0a39994aed629b831fda7dabc864" + integrity sha512-WXg2D5kMHcRxIZA3ulxdevi8/BGTXu72pfOO5vXHqcAfClfIWDSlOljROjCSOCcKvilgmHz1jDWbvFCZHjMQ5w== + dependencies: + "@babel/code-frame" "7.0.0" + chalk "2.4.2" + extend "3.0.2" + strip-ansi "5.2.0" + text-table "0.2.0" + eslint-import-resolver-node@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404" @@ -8137,6 +8768,16 @@ eslint-plugin-cypress@^2.10.3: dependencies: globals "^11.12.0" +eslint-plugin-graphql@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/eslint-plugin-graphql/-/eslint-plugin-graphql-4.0.0.tgz#d238ff2baee4d632cfcbe787a7a70a1f50428358" + integrity sha512-d5tQm24YkVvCEk29ZR5ScsgXqAGCjKlMS8lx3mS7FS/EKsWbkvXQImpvic03EpMIvNTBW5e+2xnHzXB/VHNZJw== + dependencies: + "@babel/runtime" "^7.10.0" + graphql-config "^3.0.2" + lodash.flatten "^4.4.0" + lodash.without "^4.4.0" + eslint-plugin-import@^2.20.2, eslint-plugin-import@^2.22.0: version "2.22.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e" @@ -8259,9 +8900,9 @@ eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== eslint@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.2.0.tgz#d41b2e47804b30dbabb093a967fb283d560082e6" - integrity sha512-B3BtEyaDKC5MlfDa2Ha8/D6DsS4fju95zs0hjS3HdGazw+LNayai38A25qMppK37wWGWNYSPOR6oYzlz5MHsRQ== + version "7.4.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" + integrity sha512-gU+lxhlPHu45H3JkEGgYhWhkR9wLHHEXC9FbWFnTlEkbKyZKWgWRLgf61E8zWmBuI6g5xKBph9ltg3NtZMVF8g== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" @@ -8269,6 +8910,7 @@ eslint@^7.1.0: cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" + enquirer "^2.3.5" eslint-scope "^5.1.0" eslint-utils "^2.0.0" eslint-visitor-keys "^1.2.0" @@ -8282,7 +8924,6 @@ eslint@^7.1.0: ignore "^4.0.6" import-fresh "^3.0.0" imurmurhash "^0.1.4" - inquirer "^7.0.0" is-glob "^4.0.0" js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" @@ -8523,7 +9164,7 @@ express-promise-router@^3.0.3: lodash.flattendeep "^4.0.0" methods "^1.0.0" -express@^4.17.0, express@^4.17.1: +express@^4.0.0, express@^4.17.0, express@^4.17.1: version "4.17.1" resolved "https://registry.npmjs.org/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== @@ -8559,6 +9200,13 @@ express@^4.17.0, express@^4.17.1: utils-merge "1.0.1" vary "~1.1.2" +ext@^1.1.2: + version "1.4.0" + resolved "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" + integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== + dependencies: + type "^2.0.0" + extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -8574,7 +9222,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@^3.0.0, extend@~3.0.2: +extend@3.0.2, extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -9125,6 +9773,11 @@ from@~0: resolved "https://registry.npmjs.org/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4= +fs-capacitor@^2.0.4: + version "2.0.4" + resolved "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-2.0.4.tgz#5a22e72d40ae5078b4fe64fe4d08c0d3fc88ad3c" + integrity sha512-8S4f4WsCryNw2mJJchi46YgB6CR5Ze+4L1h8ewl9tEpL4SJ3ZO+c/bS4BWhB8bK+O3TMqhuZarTitd0S0eh2pA== + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -9139,6 +9792,16 @@ fs-extra@8.1.0, fs-extra@^8.0.1, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@9.0.1, fs-extra@^9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + fs-extra@^0.30.0: version "0.30.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" @@ -9169,16 +9832,6 @@ fs-extra@^9.0.0: jsonfile "^6.0.1" universalify "^1.0.0" -fs-extra@^9.0.1: - version "9.0.1" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" - integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^1.0.0" - fs-minipass@^1.2.5: version "1.2.7" resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" @@ -9271,6 +9924,11 @@ generic-names@^2.0.1: dependencies: loader-utils "^1.1.0" +generic-pool@2.4.3: + version "2.4.3" + resolved "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff" + integrity sha1-eAw29p360FpaBF3Te+etyhGk9v8= + genfun@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537" @@ -9281,6 +9939,11 @@ gensync@^1.0.0-beta.1: resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + get-caller-file@^2.0.1: version "2.0.5" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -9417,6 +10080,13 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" +github-slugger@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" + integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== + dependencies: + emoji-regex ">=6.0.0 <=6.1.1" + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -9534,6 +10204,18 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" +globby@11.0.1, globby@^11.0.0: + version "11.0.1" + resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" + integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@8.0.2: version "8.0.2" resolved "https://registry.npmjs.org/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" @@ -9561,18 +10243,6 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.0: - version "11.0.1" - resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" - integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" @@ -9660,6 +10330,31 @@ graphiql@^1.0.0-alpha.10: regenerator-runtime "^0.13.5" theme-ui "^0.3.1" +graphql-config@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.3.tgz#58907c65ed7d6e04132321450b60e57863ea9a5f" + integrity sha512-MBY0wEjvcgJtZUyoqpPvOE1e5qPI0hJaa1gKTqjonSFiCsNHX2lykNjpOPcodmAgH1V06ELxhGnm9kcVzqvi/g== + dependencies: + "@graphql-tools/graphql-file-loader" "^6.0.0" + "@graphql-tools/json-file-loader" "^6.0.0" + "@graphql-tools/load" "^6.0.0" + "@graphql-tools/merge" "^6.0.0" + "@graphql-tools/url-loader" "^6.0.0" + "@graphql-tools/utils" "^6.0.0" + cosmiconfig "6.0.0" + minimatch "3.0.4" + string-env-interpolation "1.0.1" + tslib "^2.0.0" + +graphql-extensions@^0.12.4: + version "0.12.4" + resolved "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.4.tgz#c0aa49a20f983a2da641526d1e505996bd2b4188" + integrity sha512-GnR4LiWk3s2bGOqIh6V1JgnSXw2RCH4NOgbCFEWvB6JqWHXTlXnLZ8bRSkCiD4pltv7RHUPWqN/sGh8R6Ae/ag== + dependencies: + "@apollographql/apollo-tools" "^0.4.3" + apollo-server-env "^2.4.5" + apollo-server-types "^0.5.1" + graphql-language-service-interface@^2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0.tgz#4e2e63242c76197c4f56c61122db68187ea566b3" @@ -9695,11 +10390,56 @@ graphql-language-service@^3.0.0: graphql-language-service-interface "^2.4.0" graphql-language-service-types "^1.6.0" +graphql-subscriptions@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz#5f2fa4233eda44cf7570526adfcf3c16937aef11" + integrity sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA== + dependencies: + iterall "^1.2.1" + +graphql-tag@^2.9.2: + version "2.10.4" + resolved "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz#2f301a98219be8b178a6453bb7e33b79b66d8f83" + integrity sha512-O7vG5BT3w6Sotc26ybcvLKNTdfr4GfsIVMD+LdYqXCeJIYPRyp8BIsDOUtxw7S1PYvRw5vH3278J2EDezR6mfA== + +graphql-tools@^4.0.0: + version "4.0.8" + resolved "https://registry.npmjs.org/graphql-tools/-/graphql-tools-4.0.8.tgz#e7fb9f0d43408fb0878ba66b522ce871bafe9d30" + integrity sha512-MW+ioleBrwhRjalKjYaLQbr+920pHBgy9vM/n47sswtns8+96sRn5M/G+J1eu7IMeKWiN/9p6tmwCHU7552VJg== + dependencies: + apollo-link "^1.2.14" + apollo-utilities "^1.0.1" + deprecated-decorator "^0.1.6" + iterall "^1.1.3" + uuid "^3.1.0" + +graphql-upload@^8.0.2: + version "8.1.0" + resolved "https://registry.npmjs.org/graphql-upload/-/graphql-upload-8.1.0.tgz#6d0ab662db5677a68bfb1f2c870ab2544c14939a" + integrity sha512-U2OiDI5VxYmzRKw0Z2dmfk0zkqMRaecH9Smh1U277gVgVe9Qn+18xqf4skwr4YJszGIh7iQDZ57+5ygOK9sM/Q== + dependencies: + busboy "^0.3.1" + fs-capacitor "^2.0.4" + http-errors "^1.7.3" + object-path "^0.11.4" + graphql@15.1.0, graphql@^15.0.0: version "15.1.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.1.0.tgz#b93e28de805294ec08e1630d901db550cb8960a1" integrity sha512-0TVyfOlCGhv/DBczQkJmwXOK6fjWkjzY3Pt7wY8i0gcYXq8aogG3weCsg48m72lywKSeOqedEHvVPOvZvSD51Q== +graphql@^14.5.3: + version "14.7.0" + resolved "https://registry.npmjs.org/graphql/-/graphql-14.7.0.tgz#7fa79a80a69be4a31c27dda824dc04dac2035a72" + integrity sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA== + dependencies: + iterall "^1.2.2" + +graphql@^15.3.0: + version "15.3.0" + resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" + integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== + growly@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -10352,6 +11092,13 @@ import-fresh@^3.0.0, import-fresh@^3.1.0: parent-module "^1.0.0" resolve-from "^4.0.0" +import-from@3.0.0, import-from@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966" + integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ== + dependencies: + resolve-from "^5.0.0" + import-from@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" @@ -10359,13 +11106,6 @@ import-from@^2.1.0: dependencies: resolve-from "^3.0.0" -import-from@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966" - integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ== - dependencies: - resolve-from "^5.0.0" - import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -10579,6 +11319,11 @@ invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -10811,6 +11556,13 @@ is-generator-fn@^2.0.0: resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== +is-glob@4.0.1, is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + is-glob@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" @@ -10825,13 +11577,6 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - is-hexadecimal@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" @@ -11179,6 +11924,11 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +iterall@^1.1.3, iterall@^1.2.1, iterall@^1.2.2: + version "1.3.0" + resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" + integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== + iterate-iterator@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6" @@ -11625,6 +12375,11 @@ js-cookie@^2.2.1: resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== +js-string-escape@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" + integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -12033,6 +12788,13 @@ lazy-universal-dotenv@^3.0.1: dotenv "^8.0.0" dotenv-expand "^5.1.0" +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + left-pad@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" @@ -12302,6 +13064,11 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.assign@^4.1.0, lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -12317,6 +13084,11 @@ lodash.debounce@^4.0.8: resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + lodash.flattendeep@^4.0.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -12377,11 +13149,21 @@ lodash.uniq@^4.5.0: resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.15, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1: +lodash.without@^4.4.0: + version "4.4.0" + resolved "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" + integrity sha1-PNRXSgC2e643OpS3SHcmQFB7eqw= + +lodash@4.17.15: version "4.17.15" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1: + version "4.17.19" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + log-symbols@3.0.0, log-symbols@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" @@ -12433,12 +13215,18 @@ logform@^2.1.1: ms "^2.1.1" triple-beam "^1.3.0" -loglevel@^1.6.8: +loglevel@^1.6.7, loglevel@^1.6.8: version "1.6.8" resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -12486,7 +13274,7 @@ lowlight@~1.11.0: fault "^1.0.2" highlight.js "~9.13.0" -lru-cache@^5.1.1: +lru-cache@^5.0.0, lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== @@ -13077,6 +13865,11 @@ moment@2.26.0, moment@^2.25.3, moment@^2.26.0: resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== +moment@^2.27.0: + version "2.27.0" + resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" + integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== + monaco-editor@^0.20.0: version "0.20.0" resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea" @@ -13129,7 +13922,7 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -msw@^0.19.0: +msw@^0.19.0, msw@^0.19.5: version "0.19.5" resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== @@ -13257,6 +14050,11 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1: resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== +next-tick@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -13291,7 +14089,7 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" -node-fetch@2.6.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0: +node-fetch@2.6.0, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0: version "2.6.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== @@ -13661,6 +14459,11 @@ oauth@0.9.x: resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= +object-assign@4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" + integrity sha1-ejs9DpgGPUP0wD8uiubNUahog6A= + object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -13690,6 +14493,11 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-path@^0.11.4: + version "0.11.4" + resolved "https://registry.npmjs.org/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949" + integrity sha1-NwrnUvvzfePqcKhhwju6iRVpGUk= + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -13905,6 +14713,13 @@ os-homedir@^1.0.0: resolved "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= +os-locale@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" + integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= + dependencies: + lcid "^1.0.0" + os-name@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801" @@ -13951,6 +14766,13 @@ p-finally@^2.0.0: resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== +p-limit@3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" + integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== + dependencies: + p-try "^2.0.0" + p-limit@^1.1.0: version "1.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -14078,6 +14900,16 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" +packet-reader@0.3.1: + version "0.3.1" + resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27" + integrity sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc= + +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + pako@~1.0.5: version "1.0.11" resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -14208,7 +15040,7 @@ parse5@5.1.1: resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parseurl@~1.3.2, parseurl@~1.3.3: +parseurl@^1.3.2, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -14453,11 +15285,111 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +pg-connection-string@0.1.3, pg-connection-string@^0.1.3: + version "0.1.3" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" + integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= + pg-connection-string@2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.2.0.tgz#caab4d38a9de4fdc29c9317acceed752897de41c" integrity sha512-xB/+wxcpFipUZOQcSzcgkjcNOosGhEoPSjz06jC89lv1dj7mc9bZv6wLVy8M2fVjP0a/xN0N988YDq1L0FhK3A== +pg-connection-string@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz#c13fcb84c298d0bfa9ba12b40dd6c23d946f55d6" + integrity sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@1.*: + version "1.8.0" + resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-1.8.0.tgz#f7ec73824c37a03f076f51bfdf70e340147c4f37" + integrity sha1-9+xzgkw3oD8Hb1G/33DjQBR8Tzc= + dependencies: + generic-pool "2.4.3" + object-assign "4.1.0" + +pg-pool@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.1.tgz#5f4afc0f58063659aeefa952d36af49fa28b30e0" + integrity sha512-BQDPWUeKenVrMMDN9opfns/kZo4lxmSWhIqo+cSAF7+lfi9ZclQbr9vfnlNaPr8wYF3UYjm5X0yPAhbcgqNOdA== + +pg-protocol@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.2.5.tgz#28a1492cde11646ff2d2d06bdee42a3ba05f126c" + integrity sha512-1uYCckkuTfzz/FCefvavRywkowa6M5FohNMF5OjKrqo9PSR8gYc8poVmwwYQaBxhmQdBjhtP514eXy9/Us2xKg== + +pg-types@1.*: + version "1.13.0" + resolved "https://registry.npmjs.org/pg-types/-/pg-types-1.13.0.tgz#75f490b8a8abf75f1386ef5ec4455ecf6b345c63" + integrity sha512-lfKli0Gkl/+za/+b6lzENajczwZHc7D5kiUCZfgm914jipD2kIOIvEkAhZ8GrW3/TUoP9w8FHjwpPObBye5KQQ== + dependencies: + pg-int8 "1.0.1" + postgres-array "~1.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.0" + postgres-interval "^1.1.0" + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^6.1.0: + version "6.4.2" + resolved "https://registry.npmjs.org/pg/-/pg-6.4.2.tgz#c364011060eac7a507a2ae063eb857ece910e27f" + integrity sha1-w2QBEGDqx6UHoq4GPrhX7OkQ4n8= + dependencies: + buffer-writer "1.0.1" + js-string-escape "1.0.1" + packet-reader "0.3.1" + pg-connection-string "0.1.3" + pg-pool "1.*" + pg-types "1.*" + pgpass "1.*" + semver "4.3.2" + +pg@^8.3.0: + version "8.3.0" + resolved "https://registry.npmjs.org/pg/-/pg-8.3.0.tgz#941383300d38eef51ecb88a0188cec441ab64d81" + integrity sha512-jQPKWHWxbI09s/Z9aUvoTbvGgoj98AU7FDCcQ7kdejupn/TcNpx56v2gaOTzXkzOajmOEJEdi9eTh9cA2RVAjQ== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.3.0" + pg-pool "^3.2.1" + pg-protocol "^1.2.5" + pg-types "^2.1.0" + pgpass "1.x" + semver "4.3.2" + +pgpass@1.*, pgpass@1.x: + version "1.0.2" + resolved "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz#2a7bb41b6065b67907e91da1b07c1847c877b306" + integrity sha1-Knu0G2BltnkH6R2hsHwYR8h3swY= + dependencies: + split "^1.0.0" + +pgtools@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/pgtools/-/pgtools-0.3.0.tgz#ee7decf4183ada28299c63df71e1e73c95b5bc53" + integrity sha512-8NxDCJ8xJ6hOp9hVNZqxi+TZl7hM1Jc8pQyj8DlAbyaWnk5OsGwf3gB/UyDODdOguiim9QzbzPsslp//apO+Uw== + dependencies: + bluebird "^3.3.5" + pg "^6.1.0" + pg-connection-string "^0.1.3" + yargs "^5.0.0" + picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" @@ -14989,6 +15921,33 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^5.4.0" +postgres-array@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.3.tgz#c561fc3b266b21451fc6555384f4986d78ec80f5" + integrity sha512-5wClXrAP0+78mcsNX3/ithQ5exKvCyK5lr5NEEEeGwwM6NJdQgzIJBVxLvRW+huFpX92F2QnZ5CcokH0VhK2qQ== + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= + +postgres-date@~1.0.0, postgres-date@~1.0.4: + version "1.0.5" + resolved "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.5.tgz#710b27de5f27d550f6e80b5d34f7ba189213c2ee" + integrity sha512-pdau6GRPERdAYUQwkBnGKxEfPyhVZXG/JiS44iZWiNdSOWE09N2lUgN6yshuq6fVSon4Pm0VMXd1srUUkLe9iA== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -16342,6 +17301,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -16379,6 +17343,11 @@ resolve-dir@^1.0.0, resolve-dir@^1.0.1: expand-tilde "^2.0.0" global-modules "^1.0.0" +resolve-from@5.0.0, resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" @@ -16454,16 +17423,16 @@ ret@~0.1.10: resolved "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@0.12.0, retry@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + retry@^0.10.0: version "0.10.1" resolved "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= - reusify@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -16523,12 +17492,11 @@ rollup-plugin-dts@^1.4.6: "@babel/code-frame" "^7.8.3" rollup-plugin-esbuild@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.1.0.tgz#8e12337c63a5b1144e0c5e8adf2f1568ad4d7d69" - integrity sha512-XYqmwk4X0SPEExgilARbre/PplhLtE3q6wiZtfgIbwxJOVGXWec1Bkcux7TFTHGX3TQozzqEASTsRJCt7py/5Q== + version "2.3.0" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.3.0.tgz#7c107d4508af80d30966a148b306cb7d5b36b258" + integrity sha512-lNbWDRaClwXauaIybuaiOBRWgxcp3GkZu0UUix9yH/OHFgVohKmzlU0NtNbvfbxAcfEooyuBRAv6YCaJl1Mbpw== dependencies: "@rollup/pluginutils" "^3.1.0" - esbuild "^0.5.3" rollup-plugin-image-files@^1.4.2: version "1.4.2" @@ -16597,10 +17565,10 @@ rollup@0.64.1: "@types/estree" "0.0.39" "@types/node" "*" -rollup@2.10.x: - version "2.10.9" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.10.9.tgz#17dcc6753c619efcc1be2cf61d73a87827eebdf9" - integrity sha512-dY/EbjiWC17ZCUSyk14hkxATAMAShkMsD43XmZGWjLrgFj15M3Dw2kEkA9ns64BiLFm9PKN6vTQw8neHwK74eg== +rollup@2.23.x: + version "2.23.0" + resolved "https://registry.npmjs.org/rollup/-/rollup-2.23.0.tgz#b7ab1fee0c0e60132fd0553c4df1e9cdacfada9d" + integrity sha512-vLNmZFUGVwrnqNAJ/BvuLk1MtWzu4IuoqsH9UWK5AIdO3rt8/CSiJNvPvCIvfzrbNsqKbNzPAG1V2O4eTe2XZg== optionalDependencies: fsevents "~2.1.2" @@ -16791,6 +17759,11 @@ semver-regex@^2.0.0: resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" + integrity sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c= + semver@7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" @@ -16918,7 +17891,7 @@ setprototypeof@1.1.1: resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== -sha.js@^2.4.0, sha.js@^2.4.8: +sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: version "2.4.11" resolved "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== @@ -17540,7 +18513,7 @@ stream-shift@^1.0.0: resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== -streamsearch@~0.1.2: +streamsearch@0.1.2, streamsearch@~0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= @@ -17555,6 +18528,11 @@ string-argv@0.3.1: resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== +string-env-interpolation@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152" + integrity sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg== + string-hash@^1.1.1: version "1.1.3" resolved "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" @@ -17568,7 +18546,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-width@^1.0.1: +string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= @@ -17817,6 +18795,17 @@ stylis@3.5.0: resolved "https://registry.npmjs.org/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1" integrity sha512-pP7yXN6dwMzAR29Q0mBrabPCe0/mNO1MSr93bhay+hcZondvMMTpeGyd8nbhYJdyperNT2DRxONQuUGcJr5iPw== +subscriptions-transport-ws@0.9.17, subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16: + version "0.9.17" + resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.17.tgz#e30e40f0caae0d2781903c01a8cb51b6e2682098" + integrity sha512-hNHi2N80PBz4T0V0QhnnsMGvG3XDFDS9mS6BhZ3R12T6EBywC8d/uJscsga0cVO4DKtXCkCRrWm2sOYrbOdhEA== + dependencies: + backo2 "^1.0.2" + eventemitter3 "^3.1.0" + iterall "^1.2.1" + symbol-observable "^1.0.4" + ws "^5.2.0" + sucrase@^3.14.1: version "3.15.0" resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.15.0.tgz#78596a78be7264a65b52ed8d873883413ef0220c" @@ -17925,7 +18914,7 @@ swr@^0.2.2: dependencies: fast-deep-equal "2.0.1" -symbol-observable@^1.1.0, symbol-observable@^1.2.0: +symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== @@ -18338,6 +19327,11 @@ toposort@^2.0.2: resolved "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= +tosource@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/tosource/-/tosource-1.0.0.tgz#42d88dd116618bcf00d6106dd5446f3427902ff1" + integrity sha1-QtiN0RZhi88A1hBt1URvNCeQL/E= + touch@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" @@ -18436,6 +19430,13 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.10.tgz#b68a49e37e90a05797e590f08494dd528bf383cf" integrity sha512-UJYuKET7ez7ry0CnvfY6fPIUIZDw+UI3qvTUQeS2MyI4TgEeWAUBqy185LeaHcdJ9zG2dgFpPJU/AecXU0Afug== +ts-invariant@^0.4.0: + version "0.4.4" + resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" + integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== + dependencies: + tslib "^1.9.3" + ts-jest@^26.0.0: version "26.1.1" resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz#b98569b8a4d4025d966b3d40c81986dd1c510f8d" @@ -18499,6 +19500,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== +tslib@^2.0.0, tslib@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz#18d13fc2dce04051e20f074cc8387fd8089ce4f3" + integrity sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -18562,7 +19568,7 @@ type-fest@^0.8.1: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@~1.6.17, type-is@~1.6.18: +type-is@^1.6.16, type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -18570,6 +19576,16 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +type@^1.0.1: + version "1.2.0" + resolved "https://registry.npmjs.org/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" + integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== + +type@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" + integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== + typed-styles@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" @@ -18781,6 +19797,13 @@ universalify@^1.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== +unixify@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" + integrity sha1-OmQcjC/7zk2mg6XHDwOkYpQMIJA= + dependencies: + normalize-path "^2.1.1" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -18924,7 +19947,7 @@ util.promisify@1.0.0: define-properties "^1.1.2" object.getownpropertydescriptors "^2.0.3" -util.promisify@~1.0.0: +util.promisify@^1.0.0, util.promisify@~1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" integrity sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== @@ -18958,7 +19981,7 @@ utils-merge@1.0.1, utils-merge@1.x.x: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.0.1, uuid@^3.3.2, uuid@^3.4.0: +uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== @@ -18999,6 +20022,11 @@ v8flags@^3.1.3: dependencies: homedir-polyfill "^1.0.1" +valid-url@1.0.9: + version "1.0.9" + resolved "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" + integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA= + validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3: version "3.0.4" resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -19316,6 +20344,17 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== +websocket@1.0.31: + version "1.0.31" + resolved "https://registry.npmjs.org/websocket/-/websocket-1.0.31.tgz#e5d0f16c3340ed87670e489ecae6144c79358730" + integrity sha512-VAouplvGKPiKFDTeCCO65vYHsyay8DqoBSlzIO3fayrfOgU94lQN5a1uWVnFrMLceTJw/+fQXR5PGbUVRaHshQ== + dependencies: + debug "^2.2.0" + es5-ext "^0.10.50" + nan "^2.14.0" + typedarray-to-buffer "^3.1.5" + yaeti "^0.0.6" + whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" @@ -19328,11 +20367,16 @@ whatwg-fetch@3.0.0, whatwg-fetch@^3.0.0: resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== -whatwg-fetch@^2.0.4: +whatwg-fetch@^2.0.0, whatwg-fetch@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== +whatwg-fetch@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.2.0.tgz#8e134f701f0a4ab5fda82626f113e2b647fd16dc" + integrity sha512-SdGPoQMMnzVYThUbSrEvqTlkvC1Ux27NehaJ/GUHBfNrh5Mjg+1/uRyFMwVnxO2MrikMWvWAqUGgQOfVU4hT7w== + whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" @@ -19365,6 +20409,11 @@ whatwg-url@^8.0.0: tr46 "^2.0.2" webidl-conversions "^5.0.0" +which-module@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" + integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= + which-module@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" @@ -19403,6 +20452,11 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +window-size@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" + integrity sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU= + windows-release@^3.1.0: version "3.2.0" resolved "https://registry.npmjs.org/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f" @@ -19457,6 +20511,14 @@ worker-rpc@^0.1.0: dependencies: microevent.ts "~0.1.1" +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" @@ -19553,7 +20615,7 @@ ws@^5.2.0: dependencies: async-limiter "~1.0.0" -ws@^6.1.2, ws@^6.2.1: +ws@^6.0.0, ws@^6.1.2, ws@^6.2.1: version "6.2.1" resolved "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== @@ -19643,16 +20705,34 @@ xregexp@^4.3.0: dependencies: "@babel/runtime-corejs3" "^7.8.3" +xss@^1.0.6: + version "1.0.7" + resolved "https://registry.npmjs.org/xss/-/xss-1.0.7.tgz#a554cbd5e909324bd6893fb47fff441ad54e2a95" + integrity sha512-A9v7tblGvxu8TWXQC9rlpW96a+LN1lyw6wyhpTmmGW+FwRMactchBR3ROKSi33UPCUcUHSu8s9YP6F+K3Mw//w== + dependencies: + commander "^2.20.3" + cssfilter "0.0.10" + xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + y18n@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== +yaeti@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577" + integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc= + yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -19699,6 +20779,14 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-3.2.0.tgz#5081355d19d9d0c8c5d81ada908cb4e6d186664f" + integrity sha1-UIE1XRnZ0MjF2BrakIy05tGGZk8= + dependencies: + camelcase "^3.0.0" + lodash.assign "^4.1.0" + yargs@^13.3.2: version "13.3.2" resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -19749,6 +20837,26 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.1" +yargs@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-5.0.0.tgz#3355144977d05757dbb86d6e38ec056123b3a66e" + integrity sha1-M1UUSXfQV1fbuG1uOOwFYSOzpm4= + dependencies: + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + lodash.assign "^4.2.0" + os-locale "^1.4.0" + read-pkg-up "^1.0.1" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^1.0.2" + which-module "^1.0.0" + window-size "^0.2.0" + y18n "^3.2.1" + yargs-parser "^3.2.0" + yauzl@2.10.0, yauzl@^2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" @@ -19788,7 +20896,15 @@ yup@^0.29.1: synchronous-promise "^2.0.10" toposort "^2.0.2" -zen-observable@^0.8.15: +zen-observable-ts@^0.8.21: + version "0.8.21" + resolved "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d" + integrity sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg== + dependencies: + tslib "^1.9.3" + zen-observable "^0.8.0" + +zen-observable@^0.8.0, zen-observable@^0.8.15: version "0.8.15" resolved "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==