diff --git a/.changeset/mean-elephants-serve.md b/.changeset/mean-elephants-serve.md new file mode 100644 index 0000000000..2f048f0715 --- /dev/null +++ b/.changeset/mean-elephants-serve.md @@ -0,0 +1,9 @@ +--- +'@techdocs/cli': patch +--- + +Reunifies the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo +code back into the main [backstage](https://github.com/backstage/backstage) repo +(see [7288](https://github.com/backstage/backstage/issues/7288)). The changes +include some internal refactoring that do not affect functionality beyond the +local development setup. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b637524a52..9be92d294e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,7 +18,9 @@ /plugins/techdocs-backend @backstage/techdocs-core /plugins/ilert @backstage/reviewers @yacut /plugins/home @backstage/techdocs-core +/packages/embedded-techdocs-app @backstage/techdocs-core /packages/search-common @backstage/techdocs-core +/packages/techdocs-cli @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7e478953bc..b6e18d7256 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -62,6 +62,7 @@ Debounce declaratively deduplicated deps +dependabot destructured dev devops diff --git a/.github/workflows/techdocs-e2e.yml b/.github/workflows/techdocs-e2e.yml new file mode 100644 index 0000000000..8f166515ff --- /dev/null +++ b/.github/workflows/techdocs-e2e.yml @@ -0,0 +1,42 @@ +name: Techdocs E2E Test + +on: + pull_request: + paths-ignore: + - '.changeset/**' + - 'contrib/**' + - 'docs/**' + - 'microsite/**' + +jobs: + verify: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + + - name: install dependencies + run: yarn install --frozen-lockfile + + - name: generate types + run: yarn tsc + + - name: build techdocs-cli + working-directory: packages/techdocs-cli + run: yarn build + + - name: Install mkdocs & techdocs-core + run: python -m pip install mkdocs-techdocs-core + + - name: techdocs-cli e2e test + working-directory: packages/techdocs-cli + run: yarn test:e2e diff --git a/docs/assets/features/techdocs/techdocs-cli-serve-preview.png b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png new file mode 100644 index 0000000000..bcd48982f4 Binary files /dev/null and b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png differ diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md new file mode 100644 index 0000000000..5548adf7f0 --- /dev/null +++ b/docs/features/techdocs/cli.md @@ -0,0 +1,241 @@ +# TechDocs CLI + +Utility command line interface for managing TechDocs sites in +[Backstage](https://github.com/backstage/backstage). + +https://backstage.io/docs/features/techdocs/techdocs-overview + +## Features + +- Supports local development/preview of a TechDocs site in a Backstage app. +- Supports generation and publishing of a documentation site in a CI/CD + workflow. + +```bash +techdocs-cli --help +Usage: techdocs-cli [options] [command] + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + generate|build [options] Generate TechDocs documentation site using mkdocs. + publish [options] Publish generated TechDocs site to an external storage AWS S3, + Google GCS, etc. + serve:mkdocs [options] Serve a documentation project locally using mkdocs serve. + serve [options] Serve a documentation project locally in a Backstage app-like + environment + help [command] display help for command +``` + +## Installation + +You can always use [`npx`](https://github.com/npm/npx) to run the latest version +of `techdocs-cli` - + +```bash +npx @techdocs/cli [command] +``` + +Or you can install it using [npm](https://www.npmjs.com/package/@techdocs/cli) - + +```bash +npm install -g @techdocs/cli +techdocs-cli [command] +``` + +## Usage + +### Preview TechDocs site locally in a Backstage like environment + +```bash +techdocs-cli serve +``` + +![A preview of techdocs-cli serve command](../../assets/features/techdocs/techdocs-cli-serve-preview.png) + +By default, Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) is used to +make sure all the dependencies are installed. However, Docker can be disabled +with `--no-docker` flag. + +The command starts two local servers - an MkDocs preview server on port 8000 and +a Backstage app server on port 3000. The Backstage app has a custom TechDocs API +implementation, which uses the MkDocs preview server as a proxy to fetch the +generated documentation files and assets. + +NOTE: When using a custom `techdocs` docker image, make sure the entry point is +also `ENTRYPOINT ["mkdocs"]`. + +Command reference: + +```bash +Usage: techdocs-cli serve [options] + +Serve a documentation project locally in a Backstage app-like environment + +Options: + -i, --docker-image The mkdocs docker container to use (default: "spotify/techdocs") + --no-docker Do not use Docker, use MkDocs executable in current user environment. + --mkdocs-port Port for MkDocs server to use (default: "8000") + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Generate TechDocs site from a documentation project + +```bash +techdocs-cli generate +``` + +Alias: `techdocs-cli build` + +The generate command uses the +[`@backstage/techdocs-common`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) +package from Backstage for consistency. A Backstage app can also generate and +publish TechDocs sites if `techdocs.builder` is set to `'local'` in +`app-config.yaml`. See +[configuration reference](https://backstage.io/docs/features/techdocs/configuration). + +By default, this command uses Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) to make +sure all the dependencies are installed. But it can be disabled using +`--no-docker` flag. + +Command reference: + +```bash +techdocs-cli generate --help +Usage: techdocs-cli generate|build [options] + +Generate TechDocs documentation site using MkDocs. + +Options: + --source-dir Source directory containing mkdocs.yml and docs/ directory. (default: ".") + --output-dir Output directory containing generated TechDocs site. (default: "./site/") + --docker-image The mkdocs docker container to use (default: "spotify/techdocs:v0.3.4") + --no-pull Do not pull the latest docker image + --no-docker Do not use Docker, use MkDocs executable and plugins in current user environment. + --techdocs-ref The repository hosting documentation source files e.g. + github:https://ghe.mycompany.net.com/org/repo. + This value is same as the backstage.io/techdocs-ref annotation of the corresponding + Backstage entity. + It is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml + if not found. + --etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored + in techdocs_metadata.json. + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Publish generated TechDocs sites + +```bash +techdocs-cli publish --publisher-type --storage-name --entity +``` + +After generating a TechDocs site using `techdocs-cli generate`, use the publish +command to upload the static generated files on a cloud storage (AWS/GCS) bucket +or (Azure) container which your Backstage app can read from. + +The value for `--entity` must be the Backstage entity which the generated +TechDocs site belongs to. You can find the values in your Entity's +`catalog-info.yaml` file. If namespace is missing in the `catalog-info.yaml`, +use `default`. The directory structure used in the storage bucket is +`namespace/kind/name/`. + +Note that the values are case-sensitive. An example for `--entity` is +`default/Component/`. + +Command reference: + +```bash +Usage: techdocs-cli publish [options] + +Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc. + +Options: + --publisher-type (Required always) awsS3 | googleGcs | azureBlobStorage + - same as techdocs.publisher.type in Backstage + app-config.yaml + --storage-name (Required always) In case of AWS/GCS, use the bucket + name. In case of Azure, use container name. Same as + techdocs.publisher.[TYPE].bucketName + --entity (Required always) Entity uid separated by / in + namespace/kind/name order (case-sensitive). Example: + default/Component/myEntity + --legacyUseCaseSensitiveTripletPaths Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). + Only use if your TechDocs backend is configured the same way + --azureAccountName (Required for Azure) specify when --publisher-type + azureBlobStorage + --azureAccountKey Azure Storage Account key to use for authentication. + If not specified, you must set AZURE_TENANT_ID, + AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment + variables. + --awsRoleArn Optional AWS ARN of role to be assumed. + --awsEndpoint Optional AWS endpoint to send requests to. + --awsS3ForcePathStyle Optional AWS S3 option to force path style. + --directory Path of the directory containing generated files to + publish (default: "./site/") + -h, --help display help for command +``` + +### Migrate content for case-insensitive access + +Prior to the beta version of TechDocs (`v[0.11.0]`), TechDocs were stored in +object storage using a case-sensitive entity triplet (e.g. +`default/API/name/index.html`). This resulted in a limitation where that exact +case was required in the Backstage URL in order to read/render TechDocs content. +As of `v[0.11.0]` of the TechDocs plugin, any case is allowed in the URL (e.g. +`default/api/name`), matching the behavior of the Catalog plugin. + +Backstage instances created with TechDocs `v[0.11.0]` or later do not need this +command. However, when upgrading to this version from an older version of +TechDocs, the `migrate` command can be used prior to deployment to ensure docs +remain accessible without having to rebuild all docs. + +Prior to upgrading to `v[0.11.0]` or greater, run this command to copy all +assets to their lower-case triplet equivalents like this: + +```bash +techdocs-cli migrate --publisher-type --storage-name --verbose +``` + +Once migrated and the upgraded version of the Backstage plugin has been +deployed, you can clean up the legacy, case-sensitive triplet files by +re-running the command with the `--removeOriginal` flag passed, which _moves_ +(rather than copies) the files. Note: this deletes files and is therefore a +destructive operation that should performed with caution. + +```bash +techdocs-cli migrate --publisher-type --storage-name --removeOriginal --verbose +``` + +Afterward, update your TechDocs CLI to `v[0.7.0]` to ensure further publishing +happens using a lower-case entity triplet. + +Note: arguments for this command largely match those of the `publish` command, +depending on your chosen storage provider. Run `techdocs-cli migrate --help` for +details. + +#### Authentication + +You need to make sure that your environment is able to authenticate with the +target cloud provider. `techdocs-cli` uses the official Node.js clients provided +by AWS (v2), Google Cloud and Azure. You can authenticate using environment +variables and/or by other means (`~/.aws/credentials`, `~/.config/gcloud` etc.) + +Refer to the Authentication section of the following documentation depending +upon your cloud storage provider - + +- [Google Cloud Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-google-gcs-bucket-with-techdocs) +- [AWS S3](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs) +- [Azure Blob Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-azure-blob-storage-container-with-techdocs) + +## Development + +You are welcome to contribute to TechDocs CLI to improve it and support new +features! See the project +[README](https://github.com/backstage/backstage/blob/main/src/packages/techdocs-cli/README.md) +for more information. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index ec5c5d8d68..a24d5897ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -105,6 +105,7 @@ "features/techdocs/configuration", "features/techdocs/using-cloud-storage", "features/techdocs/configuring-ci-cd", + "features/techdocs/cli", "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" diff --git a/mkdocs.yml b/mkdocs.yml index 8c7114abbc..48df25afef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,7 +61,6 @@ nav: - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Templates (Legacy): 'features/software-templates/legacy.md' - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' - - Backstage Search: - Overview: 'features/search/README.md' - Getting Started: 'features/search/getting-started.md' @@ -78,6 +77,7 @@ nav: - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' + - CLI: 'features/techdocs/cli.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' diff --git a/package.json b/package.json index 3e6d376c46..4fc9b2352d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", + "techdocs-cli:dev": "TECHDOCS_CLI_DEV_MODE=true packages/techdocs-cli/bin/techdocs-cli", "prepare": "husky install", "lock:check": "yarn-lock-check" }, diff --git a/packages/embedded-techdocs-app/.eslintrc.js b/packages/embedded-techdocs-app/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/embedded-techdocs-app/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml new file mode 100644 index 0000000000..c40c473aa2 --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -0,0 +1,7 @@ +# NOTE: This file is used for testing techdocs-cli locally + +backend: + baseUrl: http://localhost:7000 + +techdocs: + requestUrl: http://localhost:7000/api diff --git a/packages/embedded-techdocs-app/app-config.yaml b/packages/embedded-techdocs-app/app-config.yaml new file mode 100644 index 0000000000..56e63ff97c --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.yaml @@ -0,0 +1,10 @@ +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:3000 + +techdocs: + builder: 'external' + requestUrl: http://localhost:3000/api diff --git a/packages/embedded-techdocs-app/cypress.json b/packages/embedded-techdocs-app/cypress.json new file mode 100644 index 0000000000..5de7ebffea --- /dev/null +++ b/packages/embedded-techdocs-app/cypress.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:3001", + "fixturesFolder": false, + "pluginsFile": false +} diff --git a/packages/embedded-techdocs-app/cypress/.eslintrc.json b/packages/embedded-techdocs-app/cypress/.eslintrc.json new file mode 100644 index 0000000000..2b3a458b95 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "plugins": ["cypress"], + "extends": ["plugin:cypress/recommended"], + "rules": { + "jest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "cy.contains"] + } + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true, + "optionalDependencies": true, + "peerDependencies": true, + "bundledDependencies": true + } + ] + } +} diff --git a/packages/embedded-techdocs-app/cypress/integration/app.js b/packages/embedded-techdocs-app/cypress/integration/app.js new file mode 100644 index 0000000000..d31f7a7964 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/integration/app.js @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('App', () => { + it('should render the catalog', () => { + cy.visit('/'); + cy.contains('My Company Service Catalog'); + }); +}); diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json new file mode 100644 index 0000000000..3bd52f39e2 --- /dev/null +++ b/packages/embedded-techdocs-app/package.json @@ -0,0 +1,64 @@ +{ + "name": "embedded-techdocs-app", + "version": "0.0.0", + "private": true, + "bundled": true, + "dependencies": { + "@backstage/catalog-model": "^0.9.5", + "@backstage/cli": "^0.8.0", + "@backstage/config": "^0.1.10", + "@backstage/core-app-api": "^0.1.18", + "@backstage/core-components": "^0.7.1", + "@backstage/core-plugin-api": "^0.1.11", + "@backstage/integration-react": "^0.1.12", + "@backstage/plugin-catalog": "^0.7.2", + "@backstage/plugin-techdocs": "^0.12.3", + "@backstage/test-utils": "^0.1.19", + "@backstage/theme": "^0.2.11", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "history": "^5.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^089.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dom": "*", + "cross-env": "^7.0.0", + "cypress": "^7.3.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli app:serve --config ./app-config.yaml --config ./app-config.dev.yaml", + "build": "backstage-cli app:build --config ./app-config.yaml", + "clean": "backstage-cli clean", + "test": "backstage-cli test", + "lint": "backstage-cli lint", + "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", + "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "prettier": "@spotify/prettier-config", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/packages/embedded-techdocs-app/public/android-chrome-192x192.png b/packages/embedded-techdocs-app/public/android-chrome-192x192.png new file mode 100644 index 0000000000..eec0ae25b9 Binary files /dev/null and b/packages/embedded-techdocs-app/public/android-chrome-192x192.png differ diff --git a/packages/embedded-techdocs-app/public/apple-touch-icon.png b/packages/embedded-techdocs-app/public/apple-touch-icon.png new file mode 100644 index 0000000000..3158830ac7 Binary files /dev/null and b/packages/embedded-techdocs-app/public/apple-touch-icon.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-16x16.png b/packages/embedded-techdocs-app/public/favicon-16x16.png new file mode 100644 index 0000000000..58cf61a35e Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-16x16.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-32x32.png b/packages/embedded-techdocs-app/public/favicon-32x32.png new file mode 100644 index 0000000000..c0915ece75 Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-32x32.png differ diff --git a/packages/embedded-techdocs-app/public/favicon.ico b/packages/embedded-techdocs-app/public/favicon.ico new file mode 100644 index 0000000000..5e45e5dfbd Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon.ico differ diff --git a/packages/embedded-techdocs-app/public/index.html b/packages/embedded-techdocs-app/public/index.html new file mode 100644 index 0000000000..a6102f010d --- /dev/null +++ b/packages/embedded-techdocs-app/public/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + <%= app.title %> + + + +
+ + + diff --git a/packages/embedded-techdocs-app/public/manifest.json b/packages/embedded-techdocs-app/public/manifest.json new file mode 100644 index 0000000000..4a7c1b4ec4 --- /dev/null +++ b/packages/embedded-techdocs-app/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Backstage", + "name": "Backstage", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/embedded-techdocs-app/public/robots.txt b/packages/embedded-techdocs-app/public/robots.txt new file mode 100644 index 0000000000..01b0f9a107 --- /dev/null +++ b/packages/embedded-techdocs-app/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/embedded-techdocs-app/public/safari-pinned-tab.svg b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg new file mode 100644 index 0000000000..0f500b3002 --- /dev/null +++ b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg @@ -0,0 +1 @@ +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/embedded-techdocs-app/src/App.test.tsx b/packages/embedded-techdocs-app/src/App.test.tsx new file mode 100644 index 0000000000..a5a4374976 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderWithEffects } from '@backstage/test-utils'; +import App from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, + techdocs: { + storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = await renderWithEffects(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/packages/embedded-techdocs-app/src/App.tsx b/packages/embedded-techdocs-app/src/App.tsx new file mode 100644 index 0000000000..81a495ca33 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Navigate, Route } from 'react-router'; +import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + +import { + DefaultTechDocsHome, + TechDocsIndexPage, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { apis } from './apis'; +import { Root } from './components/Root'; +import { techDocsPage } from './components/TechDocsPage'; +import * as plugins from './plugins'; + +const app = createApp({ + apis, + plugins: Object.values(plugins), +}); + +const AppProvider = app.getProvider(); +const AppRouter = app.getRouter(); + +const routes = ( + + + {/* we need this route as TechDocs header links relies on it */} + } + /> + }> + + + } + > + {techDocsPage} + + +); + +const App = () => ( + + + {routes} + + +); + +export default App; diff --git a/packages/embedded-techdocs-app/src/apis.ts b/packages/embedded-techdocs-app/src/apis.ts new file mode 100644 index 0000000000..6234e3b719 --- /dev/null +++ b/packages/embedded-techdocs-app/src/apis.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + scmIntegrationsApiRef, + ScmIntegrationsApi, +} from '@backstage/integration-react'; +import { + AnyApiFactory, + configApiRef, + createApiFactory, + DiscoveryApi, + discoveryApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + SyncResult, + TechDocsApi, + techdocsApiRef, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs'; + +// TODO: Export type from plugin-techdocs and import this here +// import { ParsedEntityId } from '@backstage/plugin-techdocs' + +/** + * Note: Override TechDocs API to use local mkdocs server instead of techdocs-backend. + */ + +class TechDocsDevStorageApi implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + + async getEntityDocs(_entityId: EntityName, path: string) { + const apiOrigin = await this.getApiOrigin(); + // Irrespective of the entity, use mkdocs server to find the file for the path. + const url = `${apiOrigin}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + async syncEntityDocs(_: EntityName): Promise { + // this is just stub of this function as we don't need to check if docs are up to date, + // we always want to retrigger a new build + return 'cached'; + } + + // Used by transformer to modify the request to assets (CSS, Image) from inside the HTML. + async getBaseUrl( + oldBaseUrl: string, + _entityId: EntityName, + path: string, + ): Promise { + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${path}`).toString(); + } +} + +class TechDocsDevApi implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getEntityMetadata(_entityId: any) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'local', + }, + spec: { + owner: 'test', + lifecycle: 'experimental', + }, + }; + } + + async getTechDocsMetadata(_entityId: EntityName) { + return { + site_name: 'Live preview environment', + site_description: '', + }; + } +} + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevStorageApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: techdocsApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +]; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx new file mode 100644 index 0000000000..c7b1c846c4 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx new file mode 100644 index 0000000000..073cf6edad --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/embedded-techdocs-app/src/components/Root/Root.tsx b/packages/embedded-techdocs-app/src/components/Root/Root.tsx new file mode 100644 index 0000000000..65e75a488a --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/Root.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; +import { + Sidebar, + SidebarPage, + sidebarConfig, + SidebarContext, + SidebarItem, + SidebarDivider, +} from '@backstage/core-components'; +import { NavLink } from 'react-router-dom'; + +const useSidebarLogoStyles = makeStyles({ + root: { + width: sidebarConfig.drawerWidthClosed, + height: 3 * sidebarConfig.logoHeight, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: -14, + }, + link: { + width: sidebarConfig.drawerWidthClosed, + marginLeft: 24, + }, +}); + +const SidebarLogo = () => { + const classes = useSidebarLogoStyles(); + const { isOpen } = useContext(SidebarContext); + + return ( +
+ + {isOpen ? : } + +
+ ); +}; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + {/* Global nav, not org-specific */} + + {/* End global nav */} + + {children} + +); diff --git a/packages/embedded-techdocs-app/src/components/Root/index.ts b/packages/embedded-techdocs-app/src/components/Root/index.ts new file mode 100644 index 0000000000..dff706f08f --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Root } from './Root'; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx new file mode 100644 index 0000000000..37e5f233d7 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2021 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 React from 'react'; + +import { Content } from '@backstage/core-components'; + +import { + Reader, + TechDocsPage, + TechDocsPageHeader, +} from '@backstage/plugin-techdocs'; + +const DefaultTechDocsPage = () => { + const techDocsMetadata = { + site_name: 'Live preview environment', + site_description: '', + }; + + return ( + + {({ entityRef, onReady }) => ( + <> + + + + + + )} + + ); +}; + +export const techDocsPage = ; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts new file mode 100644 index 0000000000..421e244613 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './TechDocsPage'; diff --git a/packages/embedded-techdocs-app/src/index.tsx b/packages/embedded-techdocs-app/src/index.tsx new file mode 100644 index 0000000000..b15bc4c102 --- /dev/null +++ b/packages/embedded-techdocs-app/src/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/embedded-techdocs-app/src/plugins.ts b/packages/embedded-techdocs-app/src/plugins.ts new file mode 100644 index 0000000000..42fc16b339 --- /dev/null +++ b/packages/embedded-techdocs-app/src/plugins.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; diff --git a/packages/embedded-techdocs-app/src/setupTests.ts b/packages/embedded-techdocs-app/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/embedded-techdocs-app/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/packages/techdocs-cli/.eslintrc.js b/packages/techdocs-cli/.eslintrc.js new file mode 100644 index 0000000000..884f559c0f --- /dev/null +++ b/packages/techdocs-cli/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + 'no-restricted-imports': 0, + }, + }, + ], +}; diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md new file mode 100644 index 0000000000..a8c6cd04fe --- /dev/null +++ b/packages/techdocs-cli/CHANGELOG.md @@ -0,0 +1,70 @@ +# @techdocs/cli + +## 0.8.2 + +### Patch Changes + +- 8fc7384: Allow to execute techdocs-cli serve using docker techdocs-container on Windows + +## 0.8.1 + +### Patch Changes + +- 0187424: Separate build and publish release steps + +## 0.8.0 + +### Minor Changes + +- c6f437a: OpenStack Swift configuration changed due to OSS SDK Client change in @backstage/techdocs-common, it was a breaking change. + PR Reference: https://github.com/backstage/backstage/pull/6839 + +### Patch Changes + +- 05f0409: Merge Jobs for Release Pull Requests and Package Publishes + +## 0.7.0 + +### Minor Changes + +- 9d1f8d8: The `techdocs-cli publish` command will now publish TechDocs content to remote + storage using the lowercase'd entity triplet as the storage path. This is in + line with the beta release of the TechDocs plugin (`v0.11.0`). + + If you have been running `techdocs-cli` prior to this version, you will need to + follow this [migration guide](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). + +## 0.6.2 + +### Patch Changes + +- f1bcf1a: Changelog (from v0.6.1 to v0.6.2) + + #### :bug: Bug Fix + + - `techdocs-cli` + - [#105](https://github.com/backstage/techdocs-cli/pull/105) Add azureAccountKey parameter back to the publish command ([@emmaindal](https://github.com/emmaindal)) + + #### :house: Internal + + - `embedded-techdocs-app` + - [#122](https://github.com/backstage/techdocs-cli/pull/122) chore(deps-dev): bump @types/node from 12.20.20 to 16.7.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#120](https://github.com/backstage/techdocs-cli/pull/120) chore(deps-dev): bump @types/react-dom from 16.9.14 to 17.0.9 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#119](https://github.com/backstage/techdocs-cli/pull/119) chore(deps-dev): bump @testing-library/user-event from 12.8.3 to 13.2.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#118](https://github.com/backstage/techdocs-cli/pull/118) chore(deps-dev): bump @testing-library/react from 10.4.9 to 12.0.0 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - Other + - [#117](https://github.com/backstage/techdocs-cli/pull/117) chore(deps): bump @backstage/plugin-catalog from 0.6.11 to 0.6.12 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#124](https://github.com/backstage/techdocs-cli/pull/124) Update release process docs ([@emmaindal](https://github.com/emmaindal)) + - [#116](https://github.com/backstage/techdocs-cli/pull/116) ignore dependabot branches for project board workflow ([@emmaindal](https://github.com/emmaindal)) + - [#106](https://github.com/backstage/techdocs-cli/pull/106) Configure dependabot for all packages ([@emmaindal](https://github.com/emmaindal)) + - [#102](https://github.com/backstage/techdocs-cli/pull/102) readme: add information about running techdocs-common locally ([@vcapretz](https://github.com/vcapretz)) + - [#103](https://github.com/backstage/techdocs-cli/pull/103) Introduce changesets and improve the publish workflow ([@minkimcello](https://github.com/minkimcello)) + - [#101](https://github.com/backstage/techdocs-cli/pull/101) update yarn lockfile to get rid of old version of node-forge ([@emmaindal](https://github.com/emmaindal)) + + #### Committers: 3 + + Thank you for contributing ❤️ + + - `Emma Indal` ([@emmaindal](https://github.com/emmaindal)) + - `Min Kim` ([@minkimcello](https://github.com/minkimcello)) + - `Vitor Capretz` ([@vcapretz](https://github.com/vcapretz)) diff --git a/packages/techdocs-cli/README.md b/packages/techdocs-cli/README.md new file mode 100644 index 0000000000..6f070147eb --- /dev/null +++ b/packages/techdocs-cli/README.md @@ -0,0 +1,68 @@ +# techdocs-cli + +[![NPM Version badge](https://img.shields.io/npm/v/@techdocs/cli)](https://www.npmjs.com/package/@techdocs/cli) + +## Usage + +See [techdocs-cli usage docs](https://backstage.io/docs/features/techdocs/cli). + +## Development + +NOTE: When we build `techdocs-cli` it copies the output `embedded-techdocs-app` +bundle into the `packages/techdocs-cli/dist` which is then published with the +`@techdocs/cli` npm package. + +### Running + +```sh +# From the root of this repository run +# NOTE: This will build the embedded-techdocs-app and copy the output into the cli dist directory +yarn build --scope @techdocs/cli + +# Now execute the binary +packages/techdocs-cli/bin/techdocs-cli + +# ... or as a shell alias in ~/.zshrc or ~/.zprofile or ~/.bashrc or similar +export PATH=/path/to/backstage/packages/techdocs-cli/bin:$PATH +``` + +If you want to test live test changes to the `packages/embedded-techdocs-app` +you can serve the app and run the CLI using the following commands: + +```sh +# Open a shell to the embedded-techdocs-app directory +cd packages/embedded-techdocs-app + +# Run the embedded-techdocs-app using dev mode +yarn start + +# In another shell use the techdocs-cli from the root of this repo +yarn techdocs-cli:dev [...options] +``` + +### Testing + +Running unit tests requires mkdocs to be installed locally: + +```sh +pip install mkdocs +pip install mkdocs-techdocs-core +``` + +Then run `yarn test`. + +### Use an example docs project + +We have created an [example documentation project](https://github.com/backstage/techdocs-container/tree/main/mock-docs) and it's shipped with [techdocs-container](https://github.com/backstage/techdocs-container) repository, for the purpose of local development. But you are free to create your own local test site. All it takes is a `docs/index.md` and `mkdocs.yml` in a directory. + +```sh +git clone https://github.com/backstage/techdocs-container.git + +cd techdocs-container/mock-docs + +# To get a view of your docs in Backstage, use: +techdocs-cli serve + +# To view the raw mkdocs site (without Backstage), use: +techdocs-cli serve:mkdocs +``` diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli new file mode 100755 index 0000000000..6c0bbd37b5 --- /dev/null +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal) { + require('..'); +} else { + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json new file mode 100644 index 0000000000..a7ac3c3d80 --- /dev/null +++ b/packages/techdocs-cli/package.json @@ -0,0 +1,70 @@ +{ + "name": "@techdocs/cli", + "description": "Utility CLI for managing TechDocs sites in Backstage.", + "version": "0.8.4", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-cli" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "types": "", + "scripts": { + "start": "nodemon --", + "build": "./scripts/build.sh", + "clean": "backstage-cli clean", + "lint": "backstage-cli lint", + "test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts", + "test:e2e": "backstage-cli test --testPathPattern src/e2e.test.ts --runInBand" + }, + "bin": { + "techdocs-cli": "bin/techdocs-cli" + }, + "devDependencies": { + "@backstage/cli": "^0.8.0", + "@types/commander": "^2.12.2", + "@types/fs-extra": "^9.0.6", + "@types/http-proxy": "^1.17.4", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dev-utils": "^9.0.4", + "@types/serve-handler": "^6.1.0", + "@types/webpack-env": "^1.15.3", + "embedded-techdocs-app": "0.0.0", + "nodemon": "^2.0.2", + "ts-node": "^10.0.0" + }, + "files": [ + "bin", + "dist" + ], + "nodemonConfig": { + "watch": "./src", + "exec": "bin/techdocs-cli", + "ext": "ts" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.7", + "@backstage/catalog-model": "^0.9.5", + "@backstage/config": "^0.1.10", + "@backstage/techdocs-common": "^0.10.4", + "@types/dockerode": "^3.2.1", + "commander": "^6.1.0", + "dockerode": "^3.2.1", + "fs-extra": "^9.0.1", + "http-proxy": "^1.18.1", + "react-dev-utils": "^11.0.4", + "serve-handler": "^6.1.3", + "winston": "^3.2.1" + } +} diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh new file mode 100755 index 0000000000..a56be00206 --- /dev/null +++ b/packages/techdocs-cli/scripts/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Copyright 2020 The Backstage Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# Build the TechDocs CLI +npx backstage-cli -- build --outputs cjs + +# Make sure to do `yarn run build` in packages/embedded-techdocs before building here. + +EMBEDDED_TECHDOCS_APP_PATH=../embedded-techdocs-app +TECHDOCS_PREVIEW_SOURCE=$EMBEDDED_TECHDOCS_APP_PATH/dist +TECHDOCS_PREVIEW_DEST=dist/techdocs-preview-bundle + +# Build the embedded-techdocs-app +pushd $EMBEDDED_TECHDOCS_APP_PATH >/dev/null +yarn build +popd >/dev/null + +cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST + +# 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/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts new file mode 100644 index 0000000000..a255805139 --- /dev/null +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import Docker from 'dockerode'; +import { + TechdocsGenerator, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import { DockerContainerRunner } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { + convertTechDocsRefToLocationAnnotation, + createLogger, +} from '../../lib/utility'; +import { stdout } from 'process'; + +export default async function generate(cmd: Command) { + // Use techdocs-common package to generate docs. Keep consistency between Backstage and CI generating docs. + // Docs can be prepared using actions/checkout or git clone, or similar paradigms on CI. The TechDocs CI workflow + // will run on the CI pipeline containing the documentation files. + + const logger = createLogger({ verbose: cmd.verbose }); + + const sourceDir = resolve(cmd.sourceDir); + const outputDir = resolve(cmd.outputDir); + const dockerImage = cmd.dockerImage; + const pullImage = cmd.pull; + + logger.info(`Using source dir ${sourceDir}`); + logger.info(`Will output generated files in ${outputDir}`); + + logger.verbose('Creating output directory if it does not exist.'); + + await fs.ensureDir(outputDir); + + const config = new ConfigReader({ + techdocs: { + generator: { + runIn: cmd.docker ? 'docker' : 'local', + dockerImage, + pullImage, + }, + }, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + let parsedLocationAnnotation = {} as ParsedLocationAnnotation; + if (cmd.techdocsRef) { + try { + parsedLocationAnnotation = convertTechDocsRefToLocationAnnotation( + cmd.techdocsRef, + ); + } catch (err) { + logger.error(err.message); + } + } + + // Generate docs using @backstage/techdocs-common + const techdocsGenerator = await TechdocsGenerator.fromConfig(config, { + logger, + containerRunner, + }); + + logger.info('Generating documentation...'); + + await techdocsGenerator.run({ + inputDir: sourceDir, + outputDir, + ...(cmd.techdocsRef + ? { + parsedLocationAnnotation, + } + : {}), + logger, + etag: cmd.etag, + ...(process.env.LOG_LEVEL === 'debug' ? { logStream: stdout } : {}), + }); + + logger.info('Done!'); +} diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts new file mode 100644 index 0000000000..776b6dad00 --- /dev/null +++ b/packages/techdocs-cli/src/commands/index.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommanderStatic } from 'commander'; +import { TechdocsGenerator } from '@backstage/techdocs-common'; + +const defaultDockerImage = TechdocsGenerator.defaultDockerImage; + +export function registerCommands(program: CommanderStatic) { + program + .command('generate') + .description('Generate TechDocs documentation site using MkDocs.') + .option( + '--source-dir ', + 'Source directory containing mkdocs.yml and docs/ directory.', + '.', + ) + .option( + '--output-dir ', + 'Output directory containing generated TechDocs site.', + './site/', + ) + .option( + '--docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option('--no-pull', 'Do not pull the latest docker image', false) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable and plugins in current user environment.', + ) + .option( + '--techdocs-ref ', + 'The repository hosting documentation source files e.g. github:https://ghe.mycompany.net.com/org/repo.' + + '\nThis value is same as the backstage.io/techdocs-ref annotation of the corresponding Backstage entity.' + + '\nIt is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml if not found.\n', + ) + .option( + '--etag ', + 'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .alias('build') + .action(lazy(() => import('./generate/generate').then(m => m.default))); + + program + .command('migrate') + .description( + 'Migrate objects with case-sensitive entity triplets to lower-case versions.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--removeOriginal', + 'Optional Files are copied by default. If flag is set, files are renamed/moved instead.', + false, + ) + .option( + '--concurrency ', + 'Optional Controls the number of API requests allowed to be performed simultaneously.', + '25', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./migrate/migrate').then(m => m.default))); + + program + .command('publish') + .description( + 'Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .requiredOption( + '--entity ', + '(Required always) Entity uid separated by / in namespace/kind/name order (case-sensitive). Example: default/Component/myEntity ', + ) + .option( + '--legacyUseCaseSensitiveTripletPaths', + 'Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). Only use if your TechDocs backend is configured the same way.', + false, + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--directory ', + 'Path of the directory containing generated files to publish', + './site/', + ) + .action(lazy(() => import('./publish/publish').then(m => m.default))); + + program + .command('serve:mkdocs') + .description('Serve a documentation project locally using MkDocs serve.') + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, run `mkdocs serve` in current user environment.', + ) + .option('-p, --port ', 'Port to serve documentation locally', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/mkdocs').then(m => m.default))); + + program + .command('serve') + .description( + 'Serve a documentation project locally in a Backstage app-like environment', + ) + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable in current user environment.', + ) + .option('--mkdocs-port ', 'Port for MkDocs server to use', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/serve').then(m => m.default))); +} + +// Wraps an action function so that it always exits and handles errors +// Humbly taken from backstage-cli's registerCommands +function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + process.exit(0); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error.message); + process.exit(1); + } + }; +} diff --git a/packages/techdocs-cli/src/commands/migrate/migrate.ts b/packages/techdocs-cli/src/commands/migrate/migrate.ts new file mode 100644 index 0000000000..1cd8fca65b --- /dev/null +++ b/packages/techdocs-cli/src/commands/migrate/migrate.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function migrate(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + if (!publisher.migrateDocsCase) { + throw new Error(`Migration not implemented for ${cmd.publisherType}`); + } + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + throw new Error(''); + } + + // Validate and parse migration arguments. + const removeOriginal = cmd.removeOriginal; + const numericConcurrency = parseInt(cmd.concurrency, 10); + + if (!Number.isInteger(numericConcurrency) || numericConcurrency <= 0) { + throw new Error( + `Concurrency must be a number greater than 1. ${cmd.concurrency} provided.`, + ); + } + + await publisher.migrateDocsCase({ + concurrency: numericConcurrency, + removeOriginal, + }); +} diff --git a/packages/techdocs-cli/src/commands/publish/publish.ts b/packages/techdocs-cli/src/commands/publish/publish.ts new file mode 100644 index 0000000000..11a47d4a71 --- /dev/null +++ b/packages/techdocs-cli/src/commands/publish/publish.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Entity } from '@backstage/catalog-model'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function publish(cmd: Command): Promise { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + return Promise.reject(new Error('')); + } + + const [namespace, kind, name] = cmd.entity.split('/'); + const entity = { + kind, + metadata: { + namespace, + name, + }, + } as Entity; + + const directory = resolve(cmd.directory); + await publisher.publish({ entity, directory }); + + return true; +} diff --git a/packages/techdocs-cli/src/commands/serve/mkdocs.ts b/packages/techdocs-cli/src/commands/serve/mkdocs.ts new file mode 100644 index 0000000000..f6779c5146 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/mkdocs.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { createLogger } from '../../lib/utility'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; + +export default async function serveMkdocs(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const dockerAddr = `http://0.0.0.0:${cmd.port}`; + const localAddr = `http://127.0.0.1:${cmd.port}`; + const expectedDevAddr = cmd.docker ? dockerAddr : localAddr; + // We want to open browser only once based on a log. + let boolOpenBrowserTriggered = false; + + const logFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line in between + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + // Logs from container is verbose. + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !boolOpenBrowserTriggered && + line.includes(`Serving on ${expectedDevAddr}`) + ) { + // Always open the local address, since 0.0.0.0 belongs to docker + logger.info(`\nStarting mkdocs server on ${localAddr}\n`); + openBrowser(localAddr); + boolOpenBrowserTriggered = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + + // Commander stores --no-docker in cmd.docker variable + const childProcess = await runMkdocsServer({ + port: cmd.port, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: logFunc, + stderrLogFunc: logFunc, + }); + + // Keep waiting for user to cancel the process + await waitForSignal([childProcess]); +} diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts new file mode 100644 index 0000000000..62682ae1eb --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import path from 'path'; +import openBrowser from 'react-dev-utils/openBrowser'; +import HTTPServer from '../../lib/httpServer'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; +import { createLogger } from '../../lib/utility'; + +export default async function serve(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + // Determine if we want to run in local dev mode or not + // This will run the backstage http server on a different port and only used + // for proxying mkdocs to the backstage app running locally (e.g. with webpack-dev-server) + const isDevMode = Object.keys(process.env).includes('TECHDOCS_CLI_DEV_MODE') + ? true + : false; + + // TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle + // a backstage app, we define app.baseUrl in the app-config.yaml. + // Hence, it is complicated to make this configurable. + const backstagePort = 3000; + const backstageBackendPort = 7000; + + const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`; + const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`; + const mkdocsExpectedDevAddr = cmd.docker ? mkdocsDockerAddr : mkdocsLocalAddr; + + let mkdocsServerHasStarted = false; + const mkdocsLogFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !mkdocsServerHasStarted && + line.includes(`Serving on ${mkdocsExpectedDevAddr}`) + ) { + mkdocsServerHasStarted = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + logger.info('Starting mkdocs server.'); + const mkdocsChildProcess = await runMkdocsServer({ + port: cmd.mkdocsPort, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: mkdocsLogFunc, + stderrLogFunc: mkdocsLogFunc, + }); + + // Wait until mkdocs server has started so that Backstage starts with docs loaded + // Takes 1-5 seconds + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise(r => setTimeout(r, 1000)); + if (mkdocsServerHasStarted) { + break; + } + logger.info('Waiting for mkdocs server to start...'); + } + + if (!mkdocsServerHasStarted) { + logger.error( + 'mkdocs server did not start. Exiting. Try re-running command with -v option for more details.', + ); + } + + // Run the embedded-techdocs Backstage app + const techdocsPreviewBundlePath = path.join( + path.dirname(require.resolve('@techdocs/cli/package.json')), + 'dist', + 'techdocs-preview-bundle', + ); + + const httpServer = new HTTPServer( + techdocsPreviewBundlePath, + isDevMode ? backstageBackendPort : backstagePort, + cmd.mkdocsPort, + cmd.verbose, + ); + + httpServer + .serve() + .catch(err => { + logger.error(err); + mkdocsChildProcess.kill(); + process.exit(1); + }) + .then(() => { + // The last three things default/component/local/ don't matter. They can be anything. + openBrowser( + `http://localhost:${backstagePort}/docs/default/component/local/`, + ); + logger.info( + `Serving docs in Backstage at http://localhost:${backstagePort}/docs/default/component/local/\nOpening browser.`, + ); + }); + + await waitForSignal([mkdocsChildProcess]); +} diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts new file mode 100644 index 0000000000..42afb5a1fa --- /dev/null +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawn } from 'child_process'; +import path from 'path'; + +const PROJECT_ROOT_DIR = path.resolve(__dirname, '..'); +const FIXTURE_DIR = path.resolve(PROJECT_ROOT_DIR, 'src/fixture'); + +describe('end-to-end', () => { + it('shows help text', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand(['--help']); + + expect(proc.combinedStdOutErr).toContain('Usage: techdocs-cli [options]'); + expect(proc.exit).toEqual(0); + }); + + it('can generate', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand(['generate', '--no-docker'], { + cwd: FIXTURE_DIR, + killAfter: 8000, + }); + + expect(proc.combinedStdOutErr).toContain('Successfully generated docs'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in mkdocs', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand( + ['serve:mkdocs', '--no-docker'], + { + cwd: FIXTURE_DIR, + killAfter: 8000, + }, + ); + + expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in backstage', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand( + ['serve', '--no-docker', '--mkdocs-port=8888'], + { + cwd: FIXTURE_DIR, + killAfter: 8000, + }, + ); + + expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); + expect(proc.combinedStdOutErr).toContain('Serving docs in Backstage at'); + expect(proc.exit).toEqual(0); + }); +}); + +type CommandResponse = { + stdout: string; + stderr: string; + combinedStdOutErr: string; + exit: number; +}; + +type ExecuteCommandOptions = { + killAfter?: number; + cwd?: string; +}; + +function executeTechDocsCliCommand( + args: string[], + opts: ExecuteCommandOptions = {}, +): Promise { + return new Promise(resolve => { + const pathToCli = path.resolve(PROJECT_ROOT_DIR, 'bin/techdocs-cli'); + const commandResponse = { + stdout: '', + stderr: '', + combinedStdOutErr: '', + exit: 0, + }; + + const listen = spawn(pathToCli, args, { + cwd: opts.cwd, + }); + + const stdOutChunks: any[] = []; + const stdErrChunks: any[] = []; + const combinedChunks: any[] = []; + + listen.stdout.on('data', data => { + stdOutChunks.push(data); + combinedChunks.push(data); + }); + + listen.stderr.on('data', data => { + stdErrChunks.push(data); + combinedChunks.push(data); + }); + + listen.on('exit', code => { + commandResponse.exit = code as number; + commandResponse.stdout = Buffer.concat(stdOutChunks).toString('utf8'); + commandResponse.stderr = Buffer.concat(stdErrChunks).toString('utf8'); + commandResponse.combinedStdOutErr = + Buffer.concat(combinedChunks).toString('utf8'); + resolve(commandResponse); + }); + + if (opts.killAfter) { + setTimeout(() => { + listen.kill('SIGTERM'); + }, opts.killAfter); + } + }); +} diff --git a/packages/techdocs-cli/src/fixture/docs/README.md b/packages/techdocs-cli/src/fixture/docs/README.md new file mode 100644 index 0000000000..c32f73f6f4 --- /dev/null +++ b/packages/techdocs-cli/src/fixture/docs/README.md @@ -0,0 +1 @@ +# Test Fixture diff --git a/packages/techdocs-cli/src/fixture/mkdocs.yml b/packages/techdocs-cli/src/fixture/mkdocs.yml new file mode 100644 index 0000000000..5d5c2a02ae --- /dev/null +++ b/packages/techdocs-cli/src/fixture/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: docs-test-fixture +site_description: Documentation site test fixture + +nav: + - HOME: README.md + +plugins: + - techdocs-core diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts new file mode 100644 index 0000000000..6f4437cbce --- /dev/null +++ b/packages/techdocs-cli/src/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import program from 'commander'; +import { registerCommands } from './commands'; +import { version } from '../package.json'; + +const main = (argv: string[]) => { + program.name('techdocs-cli').version(version); + + registerCommands(program); + + program.parse(argv); +}; + +main(process.argv); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts new file mode 100644 index 0000000000..0a4899f70d --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import { PublisherConfig } from './PublisherConfig'; + +describe('getValidPublisherConfig', () => { + it('should not allow unknown publisher types', () => { + const invalidConfig = { + publisherType: 'unknown publisher', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(invalidConfig)).toThrowError( + `Unknown publisher type ${invalidConfig.publisherType}`, + ); + }); + + describe('for azureBlobStorage', () => { + it('should require --azureAccountName', () => { + const config = { + publisherType: 'azureBlobStorage', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + 'azureBlobStorage requires --azureAccountName to be specified', + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'azureBlobStorage', + azureAccountName: 'someAccountName', + storageName: 'someContainer', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'azureBlobStorage', + ); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ), + ).toBe('someContainer'); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ), + ).toBe('someAccountName'); + }); + }); + + describe('for awsS3', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketName'), + ).toBe('someStorageName'); + }); + }); + + describe('for openStackSwift', () => { + it('should throw error on missing parameters', () => { + const config = { + publisherType: 'openStackSwift', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + `openStackSwift requires the following params to be specified: ${[ + 'osAuthUrl', + 'osSwiftUrl', + ].join(', ')}`, + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'openStackSwift', + storageName: 'someStorageName', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + osAuthUrl: 'someAuthUrl', + osSwiftUrl: 'someSwiftUrl', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'openStackSwift', + ); + expect( + actualConfig.getConfig('techdocs.publisher.openStackSwift').get(), + ).toMatchObject({ + containerName: 'someStorageName', + credentials: { + id: 'someCredentialId', + secret: 'someSecret', + }, + authUrl: 'someAuthUrl', + swiftUrl: 'someSwiftUrl', + }); + }); + }); + + describe('for googleGcs', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'googleGcs', + storageName: 'someStorageName', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'googleGcs', + ); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketName'), + ).toBe('someStorageName'); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.ts b/packages/techdocs-cli/src/lib/PublisherConfig.ts new file mode 100644 index 0000000000..6243348156 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { Command } from 'commander'; + +type Publisher = keyof typeof PublisherConfig['configFactories']; +type PublisherConfiguration = { + [p in Publisher]?: any; +} & { + type: Publisher; +}; + +/** + * Helper when working with publisher-related configurations. + */ +export class PublisherConfig { + /** + * Maps publisher-specific config keys to config getters. + */ + private static configFactories = { + awsS3: PublisherConfig.getValidAwsS3Config, + azureBlobStorage: PublisherConfig.getValidAzureConfig, + googleGcs: PublisherConfig.getValidGoogleGcsConfig, + openStackSwift: PublisherConfig.getValidOpenStackSwiftConfig, + }; + + /** + * Returns Backstage config suitable for use when instantiating a Publisher. If + * there are any missing or invalid options provided, an error is thrown. + * + * Note: This assumes that proper credentials are set in Environment + * variables for the respective GCS/AWS clients to work. + */ + static getValidConfig(cmd: Command): ConfigReader { + const publisherType = cmd.publisherType; + + if (!PublisherConfig.isKnownPublisher(publisherType)) { + throw new Error(`Unknown publisher type ${cmd.publisherType}`); + } + + return new ConfigReader({ + // This backend config is not used at all. Just something needed a create a mock discovery instance. + backend: { + baseUrl: 'http://localhost:7000', + listen: { + port: 7000, + }, + }, + techdocs: { + publisher: PublisherConfig.configFactories[publisherType](cmd), + legacyUseCaseSensitiveTripletPaths: + cmd.legacyUseCaseSensitiveTripletPaths, + }, + }); + } + + /** + * Typeguard to ensure the publisher has a known config getter. + */ + private static isKnownPublisher( + type: string, + ): type is keyof typeof PublisherConfig['configFactories'] { + return PublisherConfig.configFactories.hasOwnProperty(type); + } + + /** + * Retrieve valid AWS S3 configuration based on the command. + */ + private static getValidAwsS3Config(cmd: Command): PublisherConfiguration { + return { + type: 'awsS3', + awsS3: { + bucketName: cmd.storageName, + ...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }), + ...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }), + ...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }), + }, + }; + } + + /** + * Retrieve valid Azure Blob Storage configuration based on the command. + */ + private static getValidAzureConfig(cmd: Command): PublisherConfiguration { + if (!cmd.azureAccountName) { + throw new Error( + `azureBlobStorage requires --azureAccountName to be specified`, + ); + } + + return { + type: 'azureBlobStorage', + azureBlobStorage: { + containerName: cmd.storageName, + credentials: { + accountName: cmd.azureAccountName, + accountKey: cmd.azureAccountKey, + }, + }, + }; + } + + /** + * Retrieve valid GCS configuration based on the command. + */ + private static getValidGoogleGcsConfig(cmd: Command): PublisherConfiguration { + return { + type: 'googleGcs', + googleGcs: { + bucketName: cmd.storageName, + }, + }; + } + + /** + * Retrieves valid OpenStack Swift configuration based on the command. + */ + private static getValidOpenStackSwiftConfig( + cmd: Command, + ): PublisherConfiguration { + const missingParams = [ + 'osCredentialId', + 'osSecret', + 'osAuthUrl', + 'osSwiftUrl', + ].filter((param: string) => !cmd[param]); + + if (missingParams.length) { + throw new Error( + `openStackSwift requires the following params to be specified: ${missingParams.join( + ', ', + )}`, + ); + } + + return { + type: 'openStackSwift', + openStackSwift: { + containerName: cmd.storageName, + credentials: { + id: cmd.osCredentialId, + secret: cmd.osSecret, + }, + authUrl: cmd.osAuthUrl, + swiftUrl: cmd.osSwiftUrl, + }, + }; + } +} diff --git a/packages/techdocs-cli/src/lib/httpServer.ts b/packages/techdocs-cli/src/lib/httpServer.ts new file mode 100644 index 0000000000..0402ce4e69 --- /dev/null +++ b/packages/techdocs-cli/src/lib/httpServer.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import serveHandler from 'serve-handler'; +import http from 'http'; +import httpProxy from 'http-proxy'; +import { createLogger } from './utility'; + +export default class HTTPServer { + private readonly proxyEndpoint: string; + private readonly backstageBundleDir: string; + private readonly backstagePort: number; + private readonly mkdocsPort: number; + private readonly verbose: boolean; + + constructor( + backstageBundleDir: string, + backstagePort: number, + mkdocsPort: number, + verbose: boolean, + ) { + this.proxyEndpoint = '/api/'; + this.backstageBundleDir = backstageBundleDir; + this.backstagePort = backstagePort; + this.mkdocsPort = mkdocsPort; + this.verbose = verbose; + } + + // Create a Proxy for mkdocs server + private createProxy() { + const proxy = httpProxy.createProxyServer({ + target: `http://localhost:${this.mkdocsPort}`, + }); + + return (request: http.IncomingMessage): [httpProxy, string] => { + // If the request goes to /api/ we want to remove /api/ from the prefix of the request URL. + // e.g. ['/', 'api', pathChunks] + const [, , ...pathChunks] = request.url?.split('/') ?? []; + const forwardPath = pathChunks.join('/'); + + return [proxy, forwardPath]; + }; + } + + public async serve(): Promise { + return new Promise((resolve, reject) => { + const proxyHandler = this.createProxy(); + const server = http.createServer( + (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.url?.startsWith(this.proxyEndpoint)) { + const [proxy, forwardPath] = proxyHandler(request); + + proxy.on('error', (error: Error) => { + reject(error); + }); + + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + + request.url = forwardPath; + return proxy.web(request, response); + } + + return serveHandler(request, response, { + public: this.backstageBundleDir, + trailingSlash: true, + rewrites: [{ source: '**', destination: 'index.html' }], + }); + }, + ); + + const logger = createLogger({ verbose: false }); + server.listen(this.backstagePort, () => { + if (this.verbose) { + logger.info( + `[techdocs-preview-bundle] Running local version of Backstage at http://localhost:${this.backstagePort}`, + ); + } + resolve(server); + }); + + server.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts new file mode 100644 index 0000000000..e06541a3e8 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { runMkdocsServer } from './mkdocsServer'; +import { run } from './run'; + +jest.mock('./run', () => { + return { + run: jest.fn(), + }; +}); + +describe('runMkdocsServer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('docker', () => { + it('should run docker directly by default', async () => { + await runMkdocsServer({}); + + const quotedCwd = `"${process.cwd()}":/content`; + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + quotedCwd, + '8000:8000', + 'serve', + '--dev-addr', + '0.0.0.0:8000', + 'spotify/techdocs', + ]), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['5678:5678', '0.0.0.0:5678']), + expect.objectContaining({}), + ); + }); + + it('should accept custom docker image', async () => { + await runMkdocsServer({ dockerImage: 'my-org/techdocs' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['my-org/techdocs']), + expect.objectContaining({}), + ); + }); + }); + + describe('mkdocs', () => { + it('should run mkdocs if specified', async () => { + await runMkdocsServer({ useDocker: false }); + + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['serve', '--dev-addr', '127.0.0.1:8000']), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ useDocker: false, port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['127.0.0.1:5678']), + expect.objectContaining({}), + ); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts new file mode 100644 index 0000000000..d35edc41b5 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChildProcess } from 'child_process'; +import { run, LogFunc } from './run'; + +export const runMkdocsServer = async (options: { + port?: string; + useDocker?: boolean; + dockerImage?: string; + stdoutLogFunc?: LogFunc; + stderrLogFunc?: LogFunc; +}): Promise => { + const port = options.port ?? '8000'; + const useDocker = options.useDocker ?? true; + const dockerImage = options.dockerImage ?? 'spotify/techdocs'; + + if (useDocker) { + return await run( + 'docker', + [ + 'run', + '--rm', + '-w', + '/content', + '-v', + `"${process.cwd()}":/content`, + '-p', + `${port}:${port}`, + dockerImage, + 'serve', + '--dev-addr', + `0.0.0.0:${port}`, + ], + { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }, + ); + } + + return await run('mkdocs', ['serve', '--dev-addr', `127.0.0.1:${port}`], { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }); +}; diff --git a/packages/techdocs-cli/src/lib/run.ts b/packages/techdocs-cli/src/lib/run.ts new file mode 100644 index 0000000000..cbc6227589 --- /dev/null +++ b/packages/techdocs-cli/src/lib/run.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { spawn, SpawnOptions, ChildProcess } from 'child_process'; + +export type LogFunc = (data: Buffer | string) => void; +type SpawnOptionsPartialEnv = Omit & { + env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; +}; + +// TODO: Accept log functions to pipe logs with. +// Runs a child command, returning the child process instance. +// Use it along with waitForSignal to run a long running process e.g. mkdocs serve +export const run = async ( + name: string, + args: string[] = [], + options: SpawnOptionsPartialEnv = {}, +): Promise => { + const { stdoutLogFunc, stderrLogFunc } = options; + + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: 'true', + ...(options.env ?? {}), + }; + + // Refer: https://nodejs.org/api/child_process.html#child_process_subprocess_stdio + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + + const childProcess = spawn(name, args, { + stdio: stdio, + shell: true, + ...options, + env, + }); + + if (stdoutLogFunc && childProcess.stdout) { + childProcess.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && childProcess.stderr) { + childProcess.stderr.on('data', stderrLogFunc); + } + + return childProcess; +}; + +// Block indefinitely and wait for a signal to kill the child process(es) +// Throw error if any child process errors +// Resolves only when all processes exit with status code 0 +export async function waitForSignal( + childProcesses: Array, +): Promise { + const promises: Array> = []; + + childProcesses.forEach(childProcess => { + if (typeof childProcess.exitCode === 'number') { + if (childProcess.exitCode) { + throw new Error(`Non zero exit code from child process`); + } + return; + } + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + childProcess.kill(signal); + // exit instead of resolve. The process is shutting down and resolving a promise here logs an error + process.exit(); + }); + } + + promises.push( + new Promise((resolve, reject) => { + childProcess.once('error', error => reject(error)); + childProcess.once('exit', code => { + if (code) { + reject(new Error(`Non zero exit code from child process`)); + } else { + resolve(); + } + }); + }), + ); + }); + + await Promise.all(promises); +} diff --git a/packages/techdocs-cli/src/lib/utility.ts b/packages/techdocs-cli/src/lib/utility.ts new file mode 100644 index 0000000000..87dbbfd08d --- /dev/null +++ b/packages/techdocs-cli/src/lib/utility.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + RemoteProtocol, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import * as winston from 'winston'; + +export const convertTechDocsRefToLocationAnnotation = ( + techdocsRef: string, +): ParsedLocationAnnotation => { + // Split on the first colon for the protocol and the rest after the first split + // is the location. + const [type, target] = techdocsRef.split(/:(.+)/) as [ + RemoteProtocol?, + string?, + ]; + + if (!type || !target) { + throw new Error( + `Can not parse --techdocs-ref ${techdocsRef}. Should be of type HOST:URL.`, + ); + } + + return { type, target }; +}; + +export const createLogger = ({ + verbose = false, +}: { + verbose: boolean; +}): winston.Logger => { + const logger = winston.createLogger({ + level: verbose ? 'verbose' : 'info', + transports: [ + new winston.transports.Console({ format: winston.format.simple() }), + ], + }); + + return logger; +}; diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index c8f35508fa..5b77059f1d 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -99,6 +99,7 @@ const SKIPPED_PACKAGES = [ join('packages', 'codemods'), join('packages', 'create-app'), join('packages', 'e2e-test'), + join('packages', 'embedded-techdocs-app'), join('packages', 'storybook'), join('packages', 'techdocs-cli'), ]; diff --git a/yarn.lock b/yarn.lock index 4c42941ad9..f51a95fccd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6825,6 +6825,14 @@ dependencies: classnames "*" +"@types/clean-css@*": + version "4.2.5" + resolved "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89" + integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw== + dependencies: + "@types/node" "*" + source-map "^0.6.0" + "@types/codemirror@^0.0.108": version "0.0.108" resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz#e640422b666bf49251b384c390cdeb2362585bde" @@ -6856,6 +6864,13 @@ resolved "https://registry.npmjs.org/@types/command-exists/-/command-exists-1.2.0.tgz#d97e0ed10097090e4ab0367ed425b0312fad86f3" integrity sha512-ugsxEJfsCuqMLSuCD4PIJkp5Uk2z6TCMRCgYVuhRo5cYQY3+1xXTQkSlPtkpGHuvWMjS2KTeVQXxkXRACMbM6A== +"@types/commander@^2.12.2": + version "2.12.2" + resolved "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" + integrity sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q== + dependencies: + commander "*" + "@types/compression@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" @@ -7112,6 +7127,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.6": + version "9.0.13" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -7169,6 +7191,24 @@ resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== +"@types/html-minifier@*": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-4.0.1.tgz#9486ffc144f8d7b8f75b07939c500ac3d73617a0" + integrity sha512-6u58FWQbWP45bsxVeMJo0yk2LEsjjZsCwn0JDe/i5Edk3L+b9TR5eZ2FGaMCrLdtGYpME5AGxUqv8o+3hWKogw== + dependencies: + "@types/clean-css" "*" + "@types/relateurl" "*" + "@types/uglify-js" "*" + +"@types/html-webpack-plugin@*": + version "3.2.6" + resolved "https://registry.npmjs.org/@types/html-webpack-plugin/-/html-webpack-plugin-3.2.6.tgz#07951aaf0fa260dbf626f9644f1d13106d537625" + integrity sha512-U8uJSvlf9lwrKG6sKFnMhqY4qJw2QXad+PHlX9sqEXVUMilVt96aVvFde73tzsdXD+QH9JS6kEytuGO2JcYZog== + dependencies: + "@types/html-minifier" "*" + "@types/tapable" "^1" + "@types/webpack" "^4" + "@types/http-assert@*": version "1.5.1" resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" @@ -7692,6 +7732,17 @@ dependencies: "@types/react" "*" +"@types/react-dev-utils@^9.0.4": + version "9.0.8" + resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.8.tgz#7e4d63d1e1c71cd236c9055bc0c0dbaa3772bcf9" + integrity sha512-H/R8BvtCf9BUWPLL9a2agUWWBOKQQPkBIe5osdrgGaDJHZggQRiNN3emH16rQkzm5zi6TVuslOFrYrfMx+QTjw== + dependencies: + "@types/eslint" "*" + "@types/express" "*" + "@types/html-webpack-plugin" "*" + "@types/webpack" "^4" + "@types/webpack-dev-server" "^3" + "@types/react-dom@*", "@types/react-dom@>=16.9.0": version "17.0.9" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -7802,6 +7853,11 @@ resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.2.tgz#a1ad747fbcc6726643a8eb2c42bb804bbf34ce02" integrity sha512-i7KOGl6xdkfpq5+p2ooC+/XFIRUMkYymZ29SD8p+Ko9lesKGUsh6860ey3YM7Y+ZG7kEDGcjzyLO3sOhozqEeA== +"@types/relateurl@*": + version "0.2.29" + resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6" + integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg== + "@types/request@^2.47.1": version "2.48.5" resolved "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" @@ -7863,6 +7919,13 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-7.3.8.tgz#508a27995498d7586dcecd77c25e289bfaf90c59" integrity sha512-D/2EJvAlCEtYFEYmmlGwbGXuK886HzyCc3nZX/tkFTQdEU8jZDAgiv08P162yB17y4ZXZoq7yFAnW4GDBb9Now== +"@types/serve-handler@^6.1.0": + version "6.1.1" + resolved "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.1.tgz#629dc9a62b201ab79a216e1e46e162aa4c8d1455" + integrity sha512-bIwSmD+OV8w0t2e7EWsuQYlGoS1o5aEdVktgkXaa43Zm0qVWi21xaSRb3DQA1UXD+DJ5bRq1Rgu14ZczB+CjIQ== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.9" resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -8076,6 +8139,17 @@ "@types/expect" "^1.20.4" "@types/node" "*" +"@types/webpack-dev-server@^3": + version "3.11.6" + resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.6.tgz#d8888cfd2f0630203e13d3ed7833a4d11b8a34dc" + integrity sha512-XCph0RiiqFGetukCTC3KVnY1jwLcZ84illFRMbyFzCcWl90B/76ew0tSqF46oBhnLC4obNDG7dMO0JfTN0MgMQ== + dependencies: + "@types/connect-history-api-fallback" "*" + "@types/express" "*" + "@types/serve-static" "*" + "@types/webpack" "^4" + http-proxy-middleware "^1.0.0" + "@types/webpack-dev-server@^3.11.5": version "3.11.5" resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.5.tgz#f4a254a3dd0667c8ee4af90d42afdb4ad1d607f3" @@ -8092,6 +8166,11 @@ resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== +"@types/webpack-env@^1.15.3": + version "1.16.3" + resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a" + integrity sha512-9gtOPPkfyNoEqCQgx4qJKkuNm/x0R2hKR7fdl7zvTJyHnIisuE/LfvXOsYWL0o3qq6uiBnKZNNNzi3l0y/X+xw== + "@types/webpack-sources@*": version "0.1.6" resolved "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.6.tgz#3d21dfc2ec0ad0c77758e79362426a9ba7d7cbcb" @@ -11289,6 +11368,11 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== +commander@*: + version "8.3.0" + resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + commander@7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" @@ -11507,6 +11591,11 @@ contains-path@^0.1.0: resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -14364,6 +14453,13 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== +fast-url-parser@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" + integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= + dependencies: + punycode "^1.3.2" + fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -20342,6 +20438,18 @@ mime-db@1.49.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.32" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" @@ -22227,7 +22335,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.2: +path-is-inside@1.0.2, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -22264,6 +22372,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -23428,7 +23541,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -23577,6 +23690,11 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" +range-parser@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -25428,6 +25546,20 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" +serve-handler@^6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" + integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== + dependencies: + bytes "3.0.0" + content-disposition "0.5.2" + fast-url-parser "1.1.3" + mime-types "2.1.18" + minimatch "3.0.4" + path-is-inside "1.0.2" + path-to-regexp "2.2.1" + range-parser "1.2.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"