diff --git a/.changeset/big-months-float.md b/.changeset/big-months-float.md new file mode 100644 index 0000000000..9acb7002e7 --- /dev/null +++ b/.changeset/big-months-float.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is +duplicated in the tabs above. diff --git a/.changeset/cost-insights-two-crabs-evolve.md b/.changeset/cost-insights-two-crabs-evolve.md new file mode 100644 index 0000000000..8a932ea59c --- /dev/null +++ b/.changeset/cost-insights-two-crabs-evolve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Fixed generation of sample data in the example Cost Insights client diff --git a/.changeset/empty-dots-attend.md b/.changeset/empty-dots-attend.md new file mode 100644 index 0000000000..3903dfa22b --- /dev/null +++ b/.changeset/empty-dots-attend.md @@ -0,0 +1,8 @@ +--- +'@backstage/catalog-model': patch +'@backstage/cli': patch +'@backstage/config-loader': patch +'@backstage/plugin-scaffolder': patch +--- + +Update the json-schema dependency version. diff --git a/.changeset/khaki-rice-kick.md b/.changeset/khaki-rice-kick.md new file mode 100644 index 0000000000..54819b855a --- /dev/null +++ b/.changeset/khaki-rice-kick.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-user-settings': patch +--- + +Add Props Icon for Sidebar Item SidebarSearchField and Settings diff --git a/.changeset/little-numbers-thank.md b/.changeset/little-numbers-thank.md new file mode 100644 index 0000000000..f444a904c0 --- /dev/null +++ b/.changeset/little-numbers-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window. diff --git a/.changeset/many-sloths-cross.md b/.changeset/many-sloths-cross.md new file mode 100644 index 0000000000..07cb6d2f61 --- /dev/null +++ b/.changeset/many-sloths-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Refactor and add regression tests for create-app tasks diff --git a/.changeset/nasty-impalas-travel.md b/.changeset/nasty-impalas-travel.md new file mode 100644 index 0000000000..f6eb321628 --- /dev/null +++ b/.changeset/nasty-impalas-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Paths can be specified in backend.reading.allow to further restrict allowed targets diff --git a/.changeset/nine-bananas-mate.md b/.changeset/nine-bananas-mate.md new file mode 100644 index 0000000000..e119eb722f --- /dev/null +++ b/.changeset/nine-bananas-mate.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-app-api': patch +'@backstage/test-utils': patch +--- + +Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. diff --git a/.changeset/ninety-grapes-love.md b/.changeset/ninety-grapes-love.md new file mode 100644 index 0000000000..9143bddd30 --- /dev/null +++ b/.changeset/ninety-grapes-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-auth-backend': patch +--- + +Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken diff --git a/.changeset/ninety-spies-prove.md b/.changeset/ninety-spies-prove.md new file mode 100644 index 0000000000..509aedd66f --- /dev/null +++ b/.changeset/ninety-spies-prove.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Skip empty file names when scaffolding with nunjucks diff --git a/.changeset/rare-lemons-boil.md b/.changeset/rare-lemons-boil.md new file mode 100644 index 0000000000..b882ab0b91 --- /dev/null +++ b/.changeset/rare-lemons-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Deprecated the `BackstagePluginWithAnyOutput` type. diff --git a/.changeset/rotten-clouds-kick.md b/.changeset/rotten-clouds-kick.md new file mode 100644 index 0000000000..2c4772c6e5 --- /dev/null +++ b/.changeset/rotten-clouds-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Start exporting and marking several types as public to address errors in the API report. diff --git a/.changeset/shiny-starfishes-float.md b/.changeset/shiny-starfishes-float.md new file mode 100644 index 0000000000..0673791375 --- /dev/null +++ b/.changeset/shiny-starfishes-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a scaffolder backend module template for the `create` command. diff --git a/.changeset/silent-taxis-tan.md b/.changeset/silent-taxis-tan.md new file mode 100644 index 0000000000..286ef40a4c --- /dev/null +++ b/.changeset/silent-taxis-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Remove exports of unused types(`RouteOptions` and `RoutePath`). diff --git a/.changeset/spicy-rice-build.md b/.changeset/spicy-rice-build.md new file mode 100644 index 0000000000..c0322d778a --- /dev/null +++ b/.changeset/spicy-rice-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output. diff --git a/.changeset/techdocs-chatt-months-report-too.md b/.changeset/techdocs-chatt-months-report-too.md new file mode 100644 index 0000000000..612a4506a5 --- /dev/null +++ b/.changeset/techdocs-chatt-months-report-too.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Fixed entity triplet case handling for certain locales. diff --git a/.changeset/techdocs-chatty-months-report.md b/.changeset/techdocs-chatty-months-report.md new file mode 100644 index 0000000000..7d3fa773bf --- /dev/null +++ b/.changeset/techdocs-chatty-months-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Added OpenStack Swift case migration support. diff --git a/.changeset/techdocs-orange-cougars-relax.md b/.changeset/techdocs-orange-cougars-relax.md new file mode 100644 index 0000000000..376de6a0e5 --- /dev/null +++ b/.changeset/techdocs-orange-cougars-relax.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets diff --git a/.changeset/techdocs-sharp-knives-unite.md b/.changeset/techdocs-sharp-knives-unite.md new file mode 100644 index 0000000000..aa3625b883 --- /dev/null +++ b/.changeset/techdocs-sharp-knives-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Use a better checkbox rendering in a task list. diff --git a/.changeset/tender-gorillas-peel.md b/.changeset/tender-gorillas-peel.md new file mode 100644 index 0000000000..f7d7022740 --- /dev/null +++ b/.changeset/tender-gorillas-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1 diff --git a/.changeset/tidy-beans-reflect.md b/.changeset/tidy-beans-reflect.md new file mode 100644 index 0000000000..20796075f0 --- /dev/null +++ b/.changeset/tidy-beans-reflect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. diff --git a/.changeset/violet-panthers-care.md b/.changeset/violet-panthers-care.md new file mode 100644 index 0000000000..7a43c87f37 --- /dev/null +++ b/.changeset/violet-panthers-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +adds getDefaultProcessor method to CatalogBuilder diff --git a/.changeset/yellow-deers-act.md b/.changeset/yellow-deers-act.md new file mode 100644 index 0000000000..a9008ea41e --- /dev/null +++ b/.changeset/yellow-deers-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types. diff --git a/ADOPTERS.md b/ADOPTERS.md index 6736538fa0..cc9e178f82 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -64,4 +64,7 @@ | [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc. | | [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. | | [Palo Alto Networks](https://www.paloaltonetworks.com) | [Jeremy Guarini](https://github.com/jeremyguarini), [Brian Lomeland](https://github.com/bbbmmmlll), [Palo Alto Networks](https://github.com/PaloAltoNetworks) | Developer portal, service catalog, documentation and tooling | -| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem +| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem | +| [Tradeshift](https://www.tradeshift.com/) | [Soren Mathiasen](https://github.com/sorenmat) | Developer Portal: documentation, monitoring, service templates, service catalog for our micro services | +| [Unity](https://unity.com) | [Ted Cordery](https://github.com/TeddyBallGame) | A centralized service catalog with documentation for our service engineers. | +| [PicPay](https://www.picpay.com) | [Luis Baroni](https://github.com/lcsbaroni), [Renata Poluceno](https://github.com/renatapoluceno), [PicPay](https://github.com/picpay) | Developer portal for building services throught templates, service catalog with ownership of services, documentation and metrics providing autonomy and visibility for all. | diff --git a/docs/FAQ.md b/docs/FAQ.md index c95e10ad4e..ea4ae352dd 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,7 +117,7 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. -### Why can't I dynamically install plugins without modifications the app? +### Why can't I dynamically install plugins without modifications to the app? This decision is part of the core architecture and development flow of Backstage. Plugins have a lot of freedom in what they provide and how they are diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 1b364bcdae..133653df44 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -199,7 +199,7 @@ export a class that `implements` the target API, for example: ```ts export class IgnoringErrorApi implements ErrorApi { - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { // ignore error } } diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 7100e86ed0..57473ce4c7 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -144,7 +144,8 @@ spec: Note that to be able to read from targets that are outside of the normal integration points such as `github.com`, you'll need to explicitly allow it by -adding an entry in the `backend.reading.allow` list. For example: +adding an entry in the `backend.reading.allow` list. Paths can be specified to +further restrict targets For example: ```yml backend: @@ -153,6 +154,8 @@ backend: allow: - host: example.com - host: '*.examples.org' + - host: example.net + paths: ['/api/'] ``` ## Common to All Kinds: The Envelope diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index fbe9f36540..61d95f55c0 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -106,6 +106,12 @@ techdocs: # This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. s3ForcePathStyle: false + # (Optional) AWS Server Side Encryption + # Defaults to undefined. + # If not set, encrypted buckets will fail to publish. + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + sse: 'aws:kms' # or AES256 + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. azureBlobStorage: diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 0b14a2e136..18fdef892a 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -41,6 +41,7 @@ lint Lint a package test Run tests, forwarding args to Jest, defaulting to watch mode clean Delete cache directories +create Open up an interactive guide to creating new things in your app create-plugin Creates a new plugin in the current repository remove-plugin Removes plugin in the current repository @@ -277,6 +278,44 @@ Options: -h, --help display help for command ``` +## create + +Scope: `root` + +The `create` command opens up an interactive guide for you to create new things +in your app. If you do not pass in any options it is completely interactive, but +it is possible to pre-select what you want to create using the `--select` flag, +and provide options using `--options`, for example: + +```bash +backstage-cli create --select plugin --option id=foo +``` + +This command is typically added as script in the root `package.json` to be +executed with `yarn backstage-create`, using options that are appropriate for +the organization that owns the app repo. For example you may have it set up like +this: + +```json +{ + "scripts": { + "backstage-create": "backstage-cli create --scope internal --no-private --npm-registry https://acme.org/npm" + } +} +``` + +```text +Usage: backstage-cli create [options] + +Options: + --select Select the thing you want to be creating upfront + --option = Pre-fill options for the creation process (default: []) + --scope The scope to use for new packages + --npm-registry The package registry to use for new packages + --no-private Do not mark new packages as private + -h, --help display help for command +``` + ## create-plugin Scope: `root` diff --git a/package.json b/package.json index ab0440fa67..75aecc36ac 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", - "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", + "backstage-create": "backstage-cli create --scope backstage --no-private", + "create-plugin": "yarn backstage-create --select plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install", "prettier:check": "prettier --check .", diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index bc5e026375..dfc845f3b2 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -130,6 +130,14 @@ export interface Config { * The host may also contain a port, for example `example.com:8080`. */ host: string; + + /** + * An optional list of paths. In case they are present only targets matching + * any of them will are allowed. You can use trailing slashes to make sure only + * subdirectories are allowed, for example `/mydir/` will allow targets with + * paths like `/mydir/a` but will block paths like `/mydir2`. + */ + paths?: string[]; }>; }; diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 192decb653..0a664f9656 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -48,6 +48,19 @@ describe('rootLogger', () => { ); }); + it('redacts but ignores empty and one-character secrets', () => { + const logger = createRootLogger(); + jest.spyOn(logger, 'write'); + setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', 'Q', '']); + logger.info('Logging SECRET-1 and SECRET_2 and Q'); + + expect(logger.write).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Logging [REDACTED] and [REDACTED] and Q', + }), + ); + }); + describe('createRootLogger', () => { it('creates a new logger', () => { const oldLogger = getRootLogger(); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index b05763a42c..2a4226f88f 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -21,7 +21,7 @@ import { coloredFormat } from './formats'; import { escapeRegExp } from '../util/escapeRegExp'; let rootLogger: winston.Logger; -let redactionRegExp: RegExp; +let redactionRegExp: RegExp | undefined; /** @public */ export function getRootLogger(): winston.Logger { @@ -34,11 +34,18 @@ export function setRootLogger(newLogger: winston.Logger) { } export function setRootLoggerRedactionList(redactionList: string[]) { - if (redactionList.length) { + // Exclude secrets that are empty or just one character in length. These + // typically mean that you are running local dev or tests, or using the + // --lax flag which sets things to just 'x'. So exclude those. + const filtered = redactionList.filter(r => r.length > 1); + + if (filtered.length) { redactionRegExp = new RegExp( - `(${redactionList.map(escapeRegExp).join('|')})`, + `(${filtered.map(escapeRegExp).join('|')})`, 'g', ); + } else { + redactionRegExp = undefined; } } diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 124abb799c..9d6edee765 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -77,6 +77,10 @@ describe('FetchUrlReader', () => { { host: 'example.com:700' }, { host: '*.examples.org' }, { host: '*.examples.org:700' }, + { + host: 'foobar.org', + paths: ['/dir1/'], + }, ], }, }, @@ -106,6 +110,9 @@ describe('FetchUrlReader', () => { expect(predicate(new URL('https://examples.org:700/test'))).toBe(false); expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true); expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir1/subpath'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir12'))).toBe(false); + expect(predicate(new URL('https://foobar.org/'))).toBe(false); }); describe('read', () => { diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 732d3b9f59..19bf5fb140 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -24,6 +24,7 @@ import { SearchResponse, UrlReader, } from './types'; +import path from 'path'; /** * A UrlReader that does a plain fetch of the URL. @@ -39,18 +40,30 @@ export class FetchUrlReader implements UrlReader { * `host`: * Either full hostnames to match, or subdomain wildcard matchers with a leading `*`. * For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not. + * + * `paths`: + * An optional list of paths which are allowed. If the list is omitted all paths are allowed. */ static factory: ReaderFactory = ({ config }) => { const predicates = config .getOptionalConfigArray('backend.reading.allow') ?.map(allowConfig => { + const paths = allowConfig.getOptionalStringArray('paths'); + const checkPath = paths + ? (url: URL) => { + const targetPath = path.posix.normalize(url.pathname); + return paths.some(allowedPath => + targetPath.startsWith(allowedPath), + ); + } + : (_url: URL) => true; const host = allowConfig.getString('host'); if (host.startsWith('*.')) { const suffix = host.slice(1); - return (url: URL) => url.host.endsWith(suffix); + return (url: URL) => url.host.endsWith(suffix) && checkPath(url); } - return (url: URL) => url.host === host; + return (url: URL) => url.host === host && checkPath(url); }) ?? []; const reader = new FetchUrlReader(); diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index b64e536578..17c89ed824 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -36,7 +36,7 @@ "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.13", "ajv": "^7.0.3", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "uuid": "^8.0.0", "yup": "^0.32.9" diff --git a/packages/cli/package.json b/packages/cli/package.json index 546a1597e1..fcbc00f47d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", "@octokit/request": "^5.4.12", - "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-yaml": "^3.0.0", @@ -81,7 +81,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.2", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index a5ea565cae..c80129662c 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -263,7 +263,6 @@ export default async (cmd: Command) => { const pluginDir = isMonoRepo ? paths.resolveTargetRoot('plugins', pluginId) : paths.resolveTargetRoot(pluginId); - const ownerIds = parseOwnerIds(answers.owner); const { version: pluginVersion } = isMonoRepo ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) : { version: '0.1.0' }; @@ -318,12 +317,8 @@ export default async (cmd: Command) => { await addPluginExtensionToApp(pluginId, extensionName, name); } - if (ownerIds && ownerIds.length) { - await addCodeownersEntry( - codeownersPath!, - `/plugins/${pluginId}`, - ownerIds, - ); + if (answers.owner) { + await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner); } Task.log(); diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts new file mode 100644 index 0000000000..64067e1b7e --- /dev/null +++ b/packages/cli/src/commands/create/create.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import fs from 'fs-extra'; +import { join as joinPath } from 'path'; +import { Command } from 'commander'; +import { FactoryRegistry } from '../../lib/create/FactoryRegistry'; +import { paths } from '../../lib/paths'; +import { assertError } from '@backstage/errors'; +import { Task } from '../../lib/tasks'; + +function parseOptions(optionStrings: string[]): Record { + const options: Record = {}; + + for (const str of optionStrings) { + const [key] = str.split('=', 1); + const value = str.slice(key.length + 1); + if (!key || str[key.length] !== '=') { + throw new Error( + `Invalid option '${str}', must be of the format =`, + ); + } + options[key] = value; + } + + return options; +} + +export default async (cmd: Command) => { + const cmdOpts = cmd.opts(); + + const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select); + + const providedOptions = parseOptions(cmdOpts.option); + const options = await FactoryRegistry.populateOptions( + factory, + providedOptions, + ); + + let isMonoRepo = false; + try { + const rootPackageJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + if (rootPackageJson.workspaces) { + isMonoRepo = true; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + let defaultVersion = '0.1.0'; + try { + const rootLernaJson = await fs.readJson( + paths.resolveTargetRoot('lerna.json'), + ); + if (rootLernaJson.version) { + defaultVersion = rootLernaJson.version; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + const tempDirs = new Array(); + async function createTemporaryDirectory(name: string): Promise { + const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name)); + tempDirs.push(dir); + return dir; + } + + let modified = false; + try { + await factory.create(options, { + isMonoRepo, + defaultVersion, + scope: cmdOpts.scope?.replace(/^@/, ''), + npmRegistry: cmdOpts.npmRegistry, + private: Boolean(cmdOpts.private), + createTemporaryDirectory, + markAsModified() { + modified = true; + }, + }); + + Task.log(); + Task.log(`🎉 Successfully created ${factory.name}`); + Task.log(); + } catch (error) { + assertError(error); + Task.error(error.message); + + if (modified) { + Task.log('It seems that something went wrong in the creation process 🤔'); + Task.log(); + Task.log( + 'We have left the changes that were made intact in case you want to', + ); + Task.log( + 'continue manually, but you can also revert the changes and try again.', + ); + + Task.error(`🔥 Failed to create ${factory.name}!`); + } + } finally { + for (const dir of tempDirs) { + try { + await fs.remove(dir); + } catch (error) { + console.error( + `Failed to remove temporary directory '${dir}', ${error}`, + ); + } + } + } +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4e01a8ca9f..a37d5e9ffe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); + program + .command('create') + .storeOptionsAsProperties(false) + .description( + 'Open up an interactive guide to creating new things in your app', + ) + .option( + '--select ', + 'Select the thing you want to be creating upfront', + ) + .option( + '--option =', + 'Pre-fill options for the creation process', + (opt, arr: string[]) => [...arr, opt], + [], + ) + .option('--scope ', 'The scope to use for new packages') + .option( + '--npm-registry ', + 'The package registry to use for new packages', + ) + .option('--no-private', 'Do not mark new packages as private') + .action(lazy(() => import('./create/create').then(m => m.default))); + program .command('create-plugin') .option( diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 0390320128..8859b5d4cd 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -177,9 +177,9 @@ describe('removePlugin', () => { fse.readFileSync(mockedCodeownersPath, 'utf8'), ); await addCodeownersEntry( - testFilePath!, path.join('plugins', testPluginName), - ['@thisIsAtestTeam', 'test@gmail.com'], + '@thisIsAtestTeam test@gmail.com', + testFilePath, ); await removePluginFromCodeOwners(testFilePath, testPluginName); expect(testFileContent).toBe(codeOwnersFileContent); diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 563bd1052d..5734be95b8 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import path from 'path'; +import { paths } from '../paths'; const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; const USER_ID_RE = /^@[-\w]+$/; @@ -30,14 +31,14 @@ type CodeownersEntry = { export async function getCodeownersFilePath( rootDir: string, ): Promise { - const paths = [ + const possiblePaths = [ path.join(rootDir, '.github', 'CODEOWNERS'), path.join(rootDir, '.gitlab', 'CODEOWNERS'), path.join(rootDir, 'docs', 'CODEOWNERS'), path.join(rootDir, 'CODEOWNERS'), ]; - for (const p of paths) { + for (const p of possiblePaths) { if (await fs.pathExists(p)) { return p; } @@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean { } export function parseOwnerIds( - spaceSeparatedOwnerIds: string, + spaceSeparatedOwnerIds: string | undefined, ): string[] | undefined { if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') { return undefined; @@ -70,11 +71,24 @@ export function parseOwnerIds( } export async function addCodeownersEntry( - codeownersFilePath: string, ownedPath: string, - ownerIds: string[], -): Promise { - const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n'); + ownerStr: string, + codeownersFilePath?: string, +): Promise { + const ownerIds = parseOwnerIds(ownerStr); + if (!ownerIds || ownerIds.length === 0) { + return false; + } + + let filePath = codeownersFilePath; + if (!filePath) { + filePath = await getCodeownersFilePath(paths.targetRoot); + if (!filePath) { + return false; + } + } + + const allLines = (await fs.readFile(filePath, 'utf8')).split('\n'); // Only keep comments from the top of the file const commentLines = []; @@ -117,5 +131,7 @@ export async function addCodeownersEntry( const newLines = [...commentLines, '', ...newDeclarationLines, '']; - await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8'); + await fs.writeFile(filePath, newLines.join('\n'), 'utf8'); + + return true; } diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts new file mode 100644 index 0000000000..9072d46c4c --- /dev/null +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { AnyFactory, Prompt } from './types'; +import * as factories from './factories'; +import partition from 'lodash/partition'; + +function applyPromptMessageTransforms( + prompt: Prompt, + transforms: { + message: (msg: string) => string; + error: (msg: string) => string; + }, +): Prompt { + return { + ...prompt, + message: + prompt.message && + (async answers => { + if (typeof prompt.message === 'function') { + return transforms.message(await prompt.message(answers)); + } + return transforms.message(await prompt.message!); + }), + validate: + prompt.validate && + (async (...args) => { + const result = await prompt.validate!(...args); + if (typeof result === 'string') { + return transforms.error(result); + } + return result; + }), + }; +} + +export class FactoryRegistry { + private static factoryMap = new Map( + Object.values(factories).map(factory => [factory.name, factory]), + ); + + static async interactiveSelect(preselected?: string): Promise { + let selected = preselected; + + if (!selected) { + const answers = await inquirer.prompt<{ name: string }>([ + { + type: 'list', + name: 'name', + message: 'What do you want to create?', + choices: Array.from(this.factoryMap.values()).map(factory => ({ + name: `${factory.name} - ${factory.description}`, + value: factory.name, + })), + }, + ]); + selected = answers.name; + } + + const factory = this.factoryMap.get(selected); + if (!factory) { + throw new Error(`Unknown selection '${selected}'`); + } + return factory; + } + + static async populateOptions( + factory: AnyFactory, + provided: Record, + ): Promise> { + let currentOptions = provided; + + if (factory.optionsDiscovery) { + const discoveredOptions = await factory.optionsDiscovery(); + currentOptions = { + ...currentOptions, + ...(discoveredOptions as Record), + }; + } + + if (factory.optionsPrompts) { + const [hasAnswers, needsAnswers] = partition( + factory.optionsPrompts, + option => option.name in currentOptions, + ); + + for (const option of hasAnswers) { + const value = provided[option.name]; + + if (option.validate) { + const result = option.validate(value); + if (result !== true) { + throw new Error(`Invalid option '${option.name}'. ${result}`); + } + } + } + + currentOptions = await inquirer.prompt( + needsAnswers.map(option => + applyPromptMessageTransforms(option, { + message: chalk.blue, + error: chalk.red, + }), + ), + currentOptions, + ); + } + + return currentOptions; + } +} diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts new file mode 100644 index 0000000000..6f4a73c02d --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { backendPlugin } from './backendPlugin'; + +describe('backendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a backend plugin', async () => { + mockFs({ + '/root': { + packages: { + backend: { + 'package.json': JSON.stringify({}), + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(backendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await backendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-backend', + 'Checking Prerequisites:', + `availability plugins${sep}test-backend`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'copying index.ts', + 'templating run.ts.hbs', + 'copying setupTests.ts', + 'copying router.test.ts', + 'copying router.ts', + 'templating standaloneServer.ts.hbs', + 'Installing:', + `moving plugins${sep}test-backend`, + 'backend adding dependency', + ]); + + await expect( + fs.readJson('/root/packages/backend/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test-backend': '^1.0.0', + }, + }); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts new file mode 100644 index 0000000000..4d03a4da7b --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const backendPlugin = createFactory({ + name: 'backend-plugin', + description: 'A new backend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const id = `${options.id}-backend`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-backend-plugin', + values: { + id, + name, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) { + await Task.forItem('backend', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/backend/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/common/prompts.ts b/packages/cli/src/lib/create/factories/common/prompts.ts new file mode 100644 index 0000000000..9c7672ddfb --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/prompts.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Prompt } from '../../types'; +import { parseOwnerIds } from '../../../codeowners'; + +export function pluginIdPrompt(): Prompt<{ id: string }> { + return { + type: 'input', + name: 'id', + message: 'Enter the ID of the plugin [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the ID of the plugin'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }; +} + +export function ownerPrompt(): Prompt<{ + owner?: string; + codeOwnersPath?: string; +}> { + return { + type: 'input', + name: 'owner', + message: 'Enter an owner to add to CODEOWNERS [optional]', + when: opts => Boolean(opts.codeOwnersPath), + validate: (value: string) => { + if (!value) { + return true; + } + + const ownerIds = parseOwnerIds(value); + if (!ownerIds) { + return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).'; + } + + return true; + }, + }; +} diff --git a/packages/cli/src/lib/create/factories/common/tasks.test.ts b/packages/cli/src/lib/create/factories/common/tasks.test.ts new file mode 100644 index 0000000000..49381676e7 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep } from 'path'; +import { createMockOutputStream, mockPaths } from './testUtils'; +import { CreateContext } from '../../types'; +import { executePluginPackageTemplate } from './tasks'; + +mockPaths({ + ownDir: '/own', + targetRoot: '/root', +}); + +describe('executePluginPackageTemplate', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should execute template', async () => { + mockFs({ + '/root': { + 'yarn.lock': ` +some-package@^1.1.0: + version "1.5.0" +`, + }, + '/own': { + templates: { + 'test-template': { + 'package.json.hbs': ` +{ + "name": "my-{{id}}-plugin", + {{#if makePrivate}} + "private": true, + {{/if}} + "description": "testing", + "dependencies": { + "some-package": "{{ versionQuery 'some-package' '1.3.0' }}", + "other-package": "{{ versionQuery 'other-package' '2.3.0' }}" + } +} +`, + subdir: { + 'templated.txt.hbs': 'Hello {{id}}!', + 'not-templated.txt': 'Hello {{id}}!', + }, + }, + }, + }, + }); + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + + let modified = false; + await executePluginPackageTemplate( + { + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + markAsModified: () => { + modified = true; + }, + } as CreateContext, + { + templateName: 'test-template', + targetDir: '/target', + values: { + id: 'testing', + makePrivate: true, + }, + }, + ); + + expect(modified).toBe(true); + expect(output).toEqual([ + 'Checking Prerequisites:', + `availability ..${sep}target`, + 'creating temp dir', + 'Executing Template:', + 'templating package.json.hbs', + 'copying not-templated.txt', + 'templating templated.txt.hbs', + 'Installing:', + `moving ..${sep}target`, + ]); + await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + "name": "my-testing-plugin", + "private": true, + "description": "testing", + "dependencies": { + "some-package": "^1.1.0", + "other-package": "^2.3.0" + } +} +`); + await expect( + fs.readFile('/target/subdir/templated.txt', 'utf8'), + ).resolves.toBe('Hello testing!'); + await expect( + fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + ).resolves.toBe('Hello {{id}}!'); + }); +}); diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts new file mode 100644 index 0000000000..93af1e2b00 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import { resolve as resolvePath, relative as relativePath } from 'path'; +import { paths } from '../../../paths'; +import { Task, templatingTask } from '../../../tasks'; +import { Lockfile } from '../../../versioning'; +import { createPackageVersionProvider } from '../../../version'; +import { CreateContext } from '../../types'; + +export async function executePluginPackageTemplate( + ctx: CreateContext, + options: { + templateName: string; + targetDir: string; + values: Record; + }, +) { + const { targetDir } = options; + + let lockfile: Lockfile | undefined; + try { + lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + } catch { + /* ignored */ + } + + Task.section('Checking Prerequisites'); + const shortPluginDir = relativePath(paths.targetRoot, targetDir); + await Task.forItem('availability', shortPluginDir, async () => { + if (await fs.pathExists(targetDir)) { + throw new Error( + `A package with the same plugin ID already exists at ${chalk.cyan( + shortPluginDir, + )}. Please try again with a different ID.`, + ); + } + }); + + const tempDir = await Task.forItem('creating', 'temp dir', async () => { + return await ctx.createTemporaryDirectory('backstage-create'); + }); + + Task.section('Executing Template'); + await templatingTask( + paths.resolveOwn('templates', options.templateName), + tempDir, + options.values, + createPackageVersionProvider(lockfile), + ); + + // Format package.json if it exists + const pkgJsonPath = resolvePath(tempDir, 'package.json'); + if (await fs.pathExists(pkgJsonPath)) { + const pkgJson = await fs.readJson(pkgJsonPath); + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + } + + Task.section('Installing'); + await Task.forItem('moving', shortPluginDir, async () => { + await fs.move(tempDir, targetDir).catch(error => { + throw new Error( + `Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`, + ); + }); + }); + + ctx.markAsModified(); +} diff --git a/packages/cli/src/lib/create/factories/common/testUtils.ts b/packages/cli/src/lib/create/factories/common/testUtils.ts new file mode 100644 index 0000000000..01081a7486 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-control-regex */ + +import { WriteStream } from 'tty'; +import { resolve as resolvePath } from 'path'; +import { paths } from '../../../paths'; + +export function mockPaths(options: { + ownDir?: string; + ownRoot?: string; + targetDir?: string; + targetRoot?: string; +}): void { + const { ownDir, ownRoot, targetDir, targetRoot } = options; + if (ownDir) { + paths.ownDir = ownDir; + jest + .spyOn(paths, 'resolveOwn') + .mockImplementation((...ps) => resolvePath(ownDir, ...ps)); + } + if (ownRoot) { + jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot); + jest + .spyOn(paths, 'resolveOwnRoot') + .mockImplementation((...ps) => resolvePath(ownRoot, ...ps)); + } + if (targetDir) { + paths.targetDir = targetDir; + jest + .spyOn(paths, 'resolveTarget') + .mockImplementation((...ps) => resolvePath(targetDir, ...ps)); + } + if (targetRoot) { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath(targetRoot, ...ps)); + } +} + +export function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => { + let clean = msg; + // Remove terminal color escape sequences + clean = clean.replace(/\x1B\[\d\dm/g, ''); + // Remove any non-ascii + clean = clean.replace(/[^\x00-\x7F]+/g, ''); + clean = clean.trim(); + output.push(clean); + }, + } as unknown as WriteStream & { fd: any }, + ] as const; +} diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts new file mode 100644 index 0000000000..27ee14ea2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { frontendPlugin } from './frontendPlugin'; + +const appTsxContent = ` +import { createApp } from '@backstage/app-defaults'; + +const router = ( + + } /> + +) +`; + +describe('frontendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a frontend plugin', async () => { + mockFs({ + '/root': { + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test', + 'Checking Prerequisites:', + `availability plugins${sep}test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.tsx.hbs', + 'templating index.ts.hbs', + 'templating plugin.test.ts.hbs', + 'templating plugin.ts.hbs', + 'templating routes.ts.hbs', + 'copying setupTests.ts', + 'templating ExampleComponent.test.tsx.hbs', + 'templating ExampleComponent.tsx.hbs', + 'copying index.ts', + 'templating ExampleFetchComponent.test.tsx.hbs', + 'templating ExampleFetchComponent.tsx.hbs', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}test`, + 'app adding dependency', + 'app adding import', + ]); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from 'backstage-plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); + + it('should create a frontend plugin with more options and codeowners', async () => { + mockFs({ + '/root': { + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + owner: '@test-user', + }); + + const [, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + scope: 'internal', + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => {}, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + '@internal/plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from '@internal/plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts new file mode 100644 index 0000000000..3862ae4883 --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const frontendPlugin = createFactory({ + name: 'plugin', + description: 'A new frontend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + const extensionName = `${upperFirst(camelCase(id))}Page`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-plugin', + values: { + id, + name, + extensionName, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) { + await Task.forItem('app', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/app/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + + await Task.forItem('app', 'adding import', async () => { + const pluginsFilePath = paths.resolveTargetRoot( + 'packages/app/src/App.tsx', + ); + if (!(await fs.pathExists(pluginsFilePath))) { + return; + } + + const content = await fs.readFile(pluginsFilePath, 'utf8'); + const revLines = content.split('\n').reverse(); + + const lastImportIndex = revLines.findIndex(line => + line.match(/ from ("|').*("|')/), + ); + const lastRouteIndex = revLines.findIndex(line => + line.match(/<\/FlatRoutes/), + ); + + if (lastImportIndex !== -1 && lastRouteIndex !== -1) { + const importLine = `import { ${extensionName} } from '${name}';`; + if (!content.includes(importLine)) { + revLines.splice(lastImportIndex, 0, importLine); + } + + const componentLine = `} />`; + if (!content.includes(componentLine)) { + const [indentation] = + revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; + revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine); + } + + const newContent = revLines.reverse().join('\n'); + await fs.writeFile(pluginsFilePath, newContent, 'utf8'); + } + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts new file mode 100644 index 0000000000..0764d33e2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { frontendPlugin } from './frontendPlugin'; +export { backendPlugin } from './backendPlugin'; +export { pluginCommon } from './pluginCommon'; +export { scaffolderModule } from './scaffolderModule'; diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts new file mode 100644 index 0000000000..602fce7bbd --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { pluginCommon } from './pluginCommon'; + +describe('pluginCommon factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a common plugin package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(pluginCommon, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await pluginCommon.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-common', + 'Checking Prerequisites:', + `availability plugins${sep}test-common`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying setupTests.ts', + 'Installing:', + `moving plugins${sep}test-common`, + ]); + + await expect( + fs.readJson('/root/plugins/test-common/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-test-common', + description: 'Common functionalities for the test plugin', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts new file mode 100644 index 0000000000..1bcca2fb6a --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const pluginCommon = createFactory({ + name: 'plugin-common', + description: 'A new isomorphic common plugin package', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const suffix = `${id}-common`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${suffix}` + : `backstage-plugin-${suffix}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', suffix) + : paths.resolveTargetRoot(`backstage-plugin-${suffix}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-common-plugin-package', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${suffix}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts new file mode 100644 index 0000000000..c60c58e619 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { scaffolderModule } from './scaffolderModule'; + +describe('scaffolderModule factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a scaffolder backend module package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(scaffolderModule, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await scaffolderModule.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating module backstage-plugin-scaffolder-backend-module-test', + 'Checking Prerequisites:', + `availability plugins${sep}scaffolder-backend-module-test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying index.ts', + 'copying example.test.ts', + 'copying example.ts', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}scaffolder-backend-module-test`, + ]); + + await expect( + fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-scaffolder-backend-module-test', + description: 'The test module for @backstage/plugin-scaffolder-backend', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.ts b/packages/cli/src/lib/create/factories/scaffolderModule.ts new file mode 100644 index 0000000000..b89f0fc691 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const scaffolderModule = createFactory({ + name: 'scaffolder-module', + description: + 'An module exporting custom actions for @backstage/plugin-scaffolder-backend', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [ + { + type: 'input', + name: 'id', + message: 'Enter the name of the module [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the name of the module'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Module names must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }, + ownerPrompt(), + ], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const slug = `scaffolder-backend-module-${id}`; + + let name = `backstage-plugin-${slug}`; + if (ctx.scope) { + if (ctx.scope === 'backstage') { + name = `@backstage/plugin-${slug}`; + } else { + name = `@${ctx.scope}/backstage-plugin-${slug}`; + } + } + + Task.log(); + Task.log(`Creating module ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', slug) + : paths.resolveTargetRoot(`backstage-plugin-${slug}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'scaffolder-module', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${slug}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts new file mode 100644 index 0000000000..5a2460d0df --- /dev/null +++ b/packages/cli/src/lib/create/types.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DistinctQuestion } from 'inquirer'; + +export interface CreateContext { + /** The package scope to use for new packages */ + scope?: string; + /** The NPM registry to use for new packages */ + npmRegistry?: string; + /** Whether new packages should be marked as private */ + private: boolean; + /** Whether we are creating something in a monorepo or not */ + isMonoRepo: boolean; + /** The default version to use for new packages */ + defaultVersion: string; + + /** Creates a temporary directory. This will always be deleted after creation is done. */ + createTemporaryDirectory(name: string): Promise; + + /** Signal that the creation process got to a point where permanent modifications were made */ + markAsModified(): void; +} + +export type AnyOptions = Record; + +export type Prompt = DistinctQuestion & { name: string }; + +export interface Factory { + /** + * The name used for this factory. + */ + name: string; + + /** + * A description that describes what this factory creates to the user. + */ + description: string; + + /** + * An optional options discovery step that is run + * before the prompts to potentially fill in some of the options. + */ + optionsDiscovery?(): Promise>; + + /** + * Inquirer prompts that will be filled in either interactively or + * through command line arguments. + */ + optionsPrompts?: ReadonlyArray>; + + /** + * The main method of the factory that handles creation. + */ + create(options: TOptions, context?: CreateContext): Promise; +} + +export type AnyFactory = Factory; + +export function createFactory( + config: Factory, +): AnyFactory { + return config as AnyFactory; +} diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index e17015d6cf..c84eed435e 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -18,35 +18,40 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; +import { promisify } from 'util'; import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { exec as execCb } from 'child_process'; import { paths } from './paths'; +import { assertError } from '@backstage/errors'; + +const exec = promisify(execCb); const TASK_NAME_MAX_LENGTH = 14; export class Task { static log(name: string = '') { - process.stdout.write(`${chalk.green(name)}\n`); + process.stderr.write(`${chalk.green(name)}\n`); } static error(message: string = '') { - process.stdout.write(`\n${chalk.red(message)}\n\n`); + process.stderr.write(`\n${chalk.red(message)}\n\n`); } static section(name: string) { const title = chalk.green(`${name}:`); - process.stdout.write(`\n ${title}\n`); + process.stderr.write(`\n ${title}\n`); } static exit(code: number = 0) { process.exit(code); } - static async forItem( + static async forItem( task: string, item: string, - taskFunc: () => Promise, - ): Promise { + taskFunc: () => Promise, + ): Promise { const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH)); const spinner = ora({ @@ -56,13 +61,40 @@ export class Task { }).start(); try { - await taskFunc(); + const result = await taskFunc(); spinner.succeed(); + return result; } catch (error) { spinner.fail(); throw error; } } + + static async forCommand( + command: string, + options?: { cwd?: string; optional?: boolean }, + ) { + try { + await Task.forItem('executing', command, async () => { + await exec(command, { cwd: options?.cwd }); + }); + } catch (error) { + assertError(error); + if (error.stderr) { + process.stderr.write(error.stderr as Buffer); + } + if (error.stdout) { + process.stdout.write(error.stdout as Buffer); + } + if (options?.optional) { + Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`); + } else { + throw new Error( + `Failed to execute command '${chalk.cyan(command)}', ${error}`, + ); + } + } + } } export async function templatingTask( @@ -85,7 +117,9 @@ export async function templatingTask( const destination = destinationFile.replace(/\.hbs$/, ''); const template = await fs.readFile(file); - const compiled = handlebars.compile(template.toString()); + const compiled = handlebars.compile(template.toString(), { + strict: true, + }); const contents = compiled( { name: basename(destination), ...context }, { @@ -122,3 +156,44 @@ export async function templatingTask( } } } + +export async function addPackageDependency( + path: string, + options: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, +) { + try { + const pkgJson = await fs.readJson(path); + + const normalize = (obj: Record) => { + if (Object.keys(obj).length === 0) { + return undefined; + } + return Object.fromEntries( + Object.keys(obj) + .sort() + .map(key => [key, obj[key]]), + ); + }; + + pkgJson.dependencies = normalize({ + ...pkgJson.dependencies, + ...options.dependencies, + }); + pkgJson.devDependencies = normalize({ + ...pkgJson.devDependencies, + ...options.devDependencies, + }); + pkgJson.peerDependencies = normalize({ + ...pkgJson.peerDependencies, + ...options.peerDependencies, + }); + + await fs.writeJson(path, pkgJson, { spaces: 2 }); + } catch (error) { + throw new Error(`Failed to add package dependencies, ${error}`); + } +} diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index c69072c64d..3b512b22c3 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso import { version as devUtils } from '@backstage/dev-utils/package.json'; import { version as testUtils } from '@backstage/test-utils/package.json'; import { version as theme } from '@backstage/theme/package.json'; +import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; export const packageVersions: Record = { '@backstage/backend-common': backendCommon, @@ -53,6 +54,7 @@ export const packageVersions: Record = { '@backstage/dev-utils': devUtils, '@backstage/test-utils': testUtils, '@backstage/theme': theme, + '@backstage/plugin-scaffolder-backend': scaffolderBackend, }; export function findVersion() { diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 5cca7dcb07..37bcd94186 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -4,41 +4,43 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - {{#if privatePackage}} "private": {{privatePackage}}, - {{/if}} +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} "publishConfig": { - {{#if npmRegistry}} "registry": "{{npmRegistry}}", - {{/if}} - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "scripts": { - "start": "backstage-cli backend:dev", - "build": "backstage-cli backend:build", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", - "@backstage/config": "{{versionQuery '@backstage/config'}}", - "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", - "express": "{{versionQuery 'express' '4.17.1'}}", - "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", - "winston": "{{versionQuery 'winston' '3.2.1'}}", - "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", - "yn": "{{versionQuery 'yn' '4.0.0'}}" - }, - "devDependencies": { - "@backstage/cli": "{{versionQuery '@backstage/cli'}}", - "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", - "supertest": "{{versionQuery 'supertest' '4.0.2'}}", - "msw": "{{versionQuery 'msw' '0.35.0'}}" - }, - "files": [ - "dist" - ] - } +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/config": "{{versionQuery '@backstage/config'}}", + "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", + "express": "{{versionQuery 'express' '4.17.1'}}", + "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", + "winston": "{{versionQuery 'winston' '3.2.1'}}", + "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", + "yn": "{{versionQuery 'yn' '4.0.0'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", + "supertest": "{{versionQuery 'supertest' '4.0.2'}}", + "msw": "{{versionQuery 'msw' '0.35.0'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/.eslintrc.js b/packages/cli/templates/default-common-plugin-package/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/cli/templates/default-common-plugin-package/README.md.hbs b/packages/cli/templates/default-common-plugin-package/README.md.hbs new file mode 100644 index 0000000000..917e18d4b9 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +Welcome to the common package for the {{id}} plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/default-common-plugin-package/package.json.hbs b/packages/cli/templates/default-common-plugin-package/package.json.hbs new file mode 100644 index 0000000000..efaee496e7 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -0,0 +1,34 @@ +{ + "name": "{{name}}", + "description": "Common functionalities for the {{id}} plugin", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs new file mode 100644 index 0000000000..2e1150d74e --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs @@ -0,0 +1,19 @@ +/***/ +/** + * Common functionalities for the {{id}} plugin. + * + * @packageDocumentation + */ + +/** + * In this package you might for example declare types that are common + * between the frontend and backend plugin packages. + */ +export type CommonType = { + field: string +} + +/** + * Or you might declare some common constants. + */ +export const COMMON_CONSTANT = 1 diff --git a/packages/cli/templates/default-common-plugin-package/src/setupTests.ts b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli/templates/default-common-plugin-package/tsconfig.json b/packages/cli/templates/default-common-plugin-package/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 41376821ba..624302da93 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -4,10 +4,12 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", -{{#if privatePackage}} "private": {{privatePackage}}, +{{#if privatePackage}} + "private": {{privatePackage}}, {{/if}} "publishConfig": { -{{#if npmRegistry}} "registry": "{{npmRegistry}}", +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", {{/if}} "access": "public", "main": "dist/index.esm.js", diff --git a/packages/cli/templates/scaffolder-module/.eslintrc.js b/packages/cli/templates/scaffolder-module/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/cli/templates/scaffolder-module/README.md.hbs b/packages/cli/templates/scaffolder-module/README.md.hbs new file mode 100644 index 0000000000..8ee653a4ac --- /dev/null +++ b/packages/cli/templates/scaffolder-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend). + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/scaffolder-module/package.json.hbs b/packages/cli/templates/scaffolder-module/package.json.hbs new file mode 100644 index 0000000000..de6ffd9e3a --- /dev/null +++ b/packages/cli/templates/scaffolder-module/package.json.hbs @@ -0,0 +1,37 @@ +{ + "name": "{{name}}", + "description": "The {{id}} module for @backstage/plugin-scaffolder-backend", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build --output cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}" + }, + "devDependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts new file mode 100644 index 0000000000..e427b2c603 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PassThrough } from 'stream'; +import { createAcmeExampleAction } from './example'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('acme:example', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should call action', async () => { + const action = createAcmeExampleAction(); + + const logger = getVoidLogger(); + jest.spyOn(logger, 'info'); + + await action.handler({ + input: { + myParameter: 'test', + }, + workspacePath: '/tmp', + logger, + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory() { + // Usage of mock-fs is recommended for testing of filesystem operations + throw new Error('Not implemented'); + }, + }); + + expect(logger.info).toHaveBeenCalledWith( + 'Running example template with parameters: test', + ); + }); +}); diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts new file mode 100644 index 0000000000..c20a4bdf25 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; + +/** + * Creates an `acme:example` Scaffolder action. + * + * @remarks + * + * See {@link https://example.com} for more information. + * + * @public + */ +export function createAcmeExampleAction() { + // For more information on how to define custom actions, see + // https://backstage.io/docs/features/software-templates/writing-custom-actions + return createTemplateAction<{ + myParameter: string; + }>({ + id: 'acme:example', + description: 'Runs Yeoman on an installed Yeoman generator', + schema: { + input: { + type: 'object', + required: ['myParameter'], + properties: { + myParameter: { + title: 'An example parameter', + description: 'This is the schema for our example parameter', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + ctx.logger.info( + `Running example template with parameters: ${ctx.input.myParameter}`, + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + }, + }); +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/index.ts b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts new file mode 100644 index 0000000000..e81099f333 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts @@ -0,0 +1 @@ +export { createAcmeExampleAction } from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/actions/index.ts b/packages/cli/templates/scaffolder-module/src/actions/index.ts new file mode 100644 index 0000000000..ab6642ebb0 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/index.ts @@ -0,0 +1 @@ +export * from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/index.ts.hbs b/packages/cli/templates/scaffolder-module/src/index.ts.hbs new file mode 100644 index 0000000000..3690e43b8e --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/index.ts.hbs @@ -0,0 +1,8 @@ +/***/ +/** + * The {{id}} module for @backstage/plugin-scaffolder-backend. + * + * @packageDocumentation + */ + +export * from './actions'; diff --git a/packages/cli/templates/scaffolder-module/tsconfig.json b/packages/cli/templates/scaffolder-module/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/scaffolder-module/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 2e25d91856..76dcec35c6 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -38,7 +38,7 @@ "ajv": "^7.0.3", "chokidar": "^3.5.2", "fs-extra": "9.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", "typescript-json-schema": "^0.51.0", diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index d98b5ef5f8..48b89a6951 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -28,9 +28,9 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { Error as Error_2 } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FeatureFlag } from '@backstage/core-plugin-api'; import { FeatureFlagsApi } from '@backstage/core-plugin-api'; @@ -95,7 +95,6 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { | undefined; // (undocumented) getAllApis(): Set; - // Warning: (ae-forgotten-export) The symbol "ApiFactoryScope" needs to be exported by the entry point index.d.ts register< Api, Impl extends Api, @@ -105,6 +104,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { >(scope: ApiFactoryScope, factory: ApiFactory): boolean; } +// @public +export type ApiFactoryScope = 'default' | 'app' | 'static'; + // @public export const ApiProvider: { (props: PropsWithChildren): JSX.Element; @@ -118,6 +120,12 @@ export const ApiProvider: { }; }; +// @public +export type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + // @public export class ApiRegistry implements ApiHolder { constructor(apis: Map); @@ -197,7 +205,14 @@ export type AppOptions = { icons: AppIcons & { [key in string]: IconComponent; }; - plugins?: BackstagePluginWithAnyOutput[]; + plugins?: (Omit, 'output'> & { + output(): ( + | PluginOutput + | { + type: string; + } + )[]; + })[]; components: AppComponents; themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; @@ -238,8 +253,6 @@ export class AppThemeSelector implements AppThemeApi { // @public export class AtlassianAuth { - // Warning: (ae-forgotten-export) The symbol "OAuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -261,6 +274,15 @@ export class Auth0Auth { }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; } +// @public +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export type BackstageApp = { getPlugins(): BackstagePlugin[]; @@ -269,7 +291,7 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public +// @public @deprecated export type BackstagePluginWithAnyOutput = Omit< BackstagePlugin, 'output' @@ -327,11 +349,11 @@ export class ErrorAlerter implements ErrorApi { constructor(alertApi: AlertApi, errorApi: ErrorApi); // (undocumented) error$(): Observable<{ - error: Error_2; - context?: ErrorContext | undefined; + error: ErrorApiError; + context?: ErrorApiErrorContext | undefined; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -339,10 +361,10 @@ export class ErrorApiForwarder implements ErrorApi { // (undocumented) error$(): Observable<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -378,6 +400,8 @@ export type FlatRoutesProps = { // @public export class GithubAuth implements OAuthApi, SessionApi { // Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts + // + // @deprecated constructor(sessionManager: SessionManager); // (undocumented) static create({ @@ -479,12 +503,11 @@ export class OAuth2 BackstageIdentityApi, SessionApi { + // @deprecated constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }); - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -493,7 +516,7 @@ export class OAuth2 oauthRequestApi, defaultScopes, scopeTransform, - }: CreateOptions): OAuth2; + }: OAuth2CreateOptions): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -515,6 +538,11 @@ export class OAuth2 signOut(): Promise; } +// @public +export type OAuth2CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + // @public export type OAuth2Session = { providerInfo: { @@ -527,6 +555,12 @@ export type OAuth2Session = { backstageIdentity: BackstageIdentity; }; +// @public +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + // @public export class OAuthRequestManager implements OAuthRequestApi { // (undocumented) @@ -549,25 +583,31 @@ export class OktaAuth { // @public export class OneLoginAuth { - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, environment, provider, oauthRequestApi, - }: CreateOptions_2): typeof oneloginAuthApiRef.T; + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T; } +// @public +export type OneLoginAuthCreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - // Warning: (ae-forgotten-export) The symbol "SamlSession" needs to be exported by the entry point index.d.ts + // @deprecated constructor(sessionManager: SessionManager); - // Warning: (ae-forgotten-export) The symbol "AuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -588,6 +628,13 @@ export class SamlAuth signOut(): Promise; } +// @public +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // @public export type SignInPageProps = { onResult(result: SignInResult): void; @@ -603,7 +650,7 @@ export type SignInResult = { // @public export class UnhandledErrorForwarder { - static forward(errorApi: ErrorApi, errorContext: ErrorContext): void; + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; } // @public @@ -632,8 +679,4 @@ export class WebStorage implements StorageApi { // (undocumented) set(key: string, data: T): Promise; } - -// Warnings were encountered during analysis: -// -// src/apis/system/ApiProvider.d.ts:15:5 - (ae-forgotten-export) The symbol "ApiProviderProps" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts index 2111798d6a..350213d938 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, + AlertApi, +} from '@backstage/core-plugin-api'; /** * Decorates an ErrorApi by also forwarding error messages @@ -27,7 +32,7 @@ export class ErrorAlerter implements ErrorApi { private readonly errorApi: ErrorApi, ) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (!context?.hidden) { this.alertApi.post({ message: error.message, severity: 'error' }); } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index cd4564a050..f67c00d991 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { PublishSubject } from '../../../lib/subjects'; @@ -26,14 +30,14 @@ import { PublishSubject } from '../../../lib/subjects'; export class ErrorApiForwarder implements ErrorApi { private readonly subject = new PublishSubject<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>(); - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { this.subject.next({ error, context }); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ error: Error; context?: ErrorApiErrorContext }> { return this.subject; } } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts index 16e473fb3a..8e697f12b2 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts @@ -1,4 +1,8 @@ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; /* * Copyright 2020 Spotify AB @@ -25,11 +29,11 @@ export class UnhandledErrorForwarder { /** * Add event listener, such that unhandled errors can be forwarded using an given `ErrorApi` instance */ - static forward(errorApi: ErrorApi, errorContext: ErrorContext) { + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext) { window.addEventListener( 'unhandledrejection', (e: PromiseRejectionEvent) => { - errorApi.post(e.reason as Error, errorContext); + errorApi.post(e.reason as ErrorApiError, errorContext); }, ); } diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts index 04bfff028d..8bcd4cb7a5 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -21,7 +21,7 @@ describe('GithubAuth', () => { const getSession = jest .fn() .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const githubAuth = new GithubAuth({ getSession } as any); + const githubAuth = new (GithubAuth as any)({ getSession }) as GithubAuth; expect(await githubAuth.getAccessToken()).toBe('access-token'); expect(getSession).toBeCalledTimes(1); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts index 885e80da7c..3e9c899346 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -116,6 +116,9 @@ export default class GithubAuth implements OAuthApi, SessionApi { return new GithubAuth(sessionManagerMux); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index bbc9d23ccc..50333f07a0 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -25,3 +25,4 @@ export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; export * from './atlassian'; +export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 45937a68f8..403e8445d8 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -32,7 +32,11 @@ import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; -type CreateOptions = OAuthApiCreateOptions & { +/** + * OAuth2 create options. + * @public + */ +export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; }; @@ -73,7 +77,7 @@ export default class OAuth2 oauthRequestApi, defaultScopes = [], scopeTransform = x => x, - }: CreateOptions) { + }: OAuth2CreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -114,6 +118,9 @@ export default class OAuth2 private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts index 4ada35846a..fb8b0e6c64 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -16,6 +16,7 @@ import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; +export type { OAuth2CreateOptions } from './OAuth2'; /** * Session information for generic OAuth2 auth. * diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts index 5f933b9c6e..93b9f6634c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -22,7 +22,11 @@ import { } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; -type CreateOptions = { +/** + * OneLogin auth provider create options. + * @public + */ +export type OneLoginAuthCreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; @@ -58,7 +62,7 @@ export default class OneLoginAuth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof oneloginAuthApiRef.T { + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts index e1826f17dd..8504d95611 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts @@ -15,3 +15,4 @@ */ export { default as OneLoginAuth } from './OneLoginAuth'; +export type { OneLoginAuthCreateOptions } from './OneLoginAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index 63985979de..c1b70e963d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -79,6 +79,9 @@ export default class SamlAuth return this.sessionManager.sessionState$(); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts index 930e6cb115..f9dc2895af 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { default as SamlAuth } from './SamlAuth'; +export type { SamlSession } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts index 89343e9e06..825f433cec 100644 --- a/packages/core-app-api/src/apis/implementations/auth/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/types.ts @@ -20,11 +20,19 @@ import { OAuthRequestApi, } from '@backstage/core-plugin-api'; +/** + * Create options for OAuth APIs. + * @public + */ export type OAuthApiCreateOptions = AuthApiCreateOptions & { oauthRequestApi: OAuthRequestApi; defaultScopes?: string[]; }; +/** + * Generic create options for auth APIs. + * @public + */ export type AuthApiCreateOptions = { discoveryApi: DiscoveryApi; environment?: string; diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts index 880f075930..5f56793cae 100644 --- a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts @@ -22,7 +22,11 @@ import { AnyApiFactory, } from '@backstage/core-plugin-api'; -type ApiFactoryScope = +/** + * Scope type when registering API factories. + * @public + */ +export type ApiFactoryScope = | 'default' // Default factories registered by core and plugins | 'app' // Factories registered in the app, overriding default ones | 'static'; // APIs that can't be overridden, e.g. config diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx index 73bbdd6f77..c75f883a51 100644 --- a/packages/core-app-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -23,7 +23,11 @@ import { createVersionedContext, } from '@backstage/version-bridge'; -type ApiProviderProps = { +/** + * Prop types for the ApiProvider component. + * @public + */ +export type ApiProviderProps = { apis: ApiHolder; children: ReactNode; }; diff --git a/packages/core-app-api/src/apis/system/index.ts b/packages/core-app-api/src/apis/system/index.ts index 23e1a9a4b8..56c42f1e2a 100644 --- a/packages/core-app-api/src/apis/system/index.ts +++ b/packages/core-app-api/src/apis/system/index.ts @@ -15,7 +15,9 @@ */ export { ApiProvider } from './ApiProvider'; +export type { ApiProviderProps } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; export { ApiResolver } from './ApiResolver'; export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export type { ApiFactoryScope } from './ApiFactoryRegistry'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index f2d1976f87..7166136054 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -200,6 +200,7 @@ export type AppRouteBinder = < * * @public * @remarks + * @deprecated Will be removed * * The `type: string` type is there to handle output from newer or older plugin * API versions that might not be supported by this version of the app API, but @@ -246,7 +247,9 @@ export type AppOptions = { /** * A list of all plugins to include in the app. */ - plugins?: BackstagePluginWithAnyOutput[]; + plugins?: (Omit, 'output'> & { + output(): (PluginOutput | { type: string })[]; + })[]; /** * Supply components to the app to override the default ones. diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 40495ca0e0..76d346031e 100644 --- a/packages/core-components/src/layout/Header/Header.tsx +++ b/packages/core-components/src/layout/Header/Header.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles( gridArea: 'pageHeader', padding: theme.spacing(3), width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + boxShadow: theme.shadows[4], position: 'relative', zIndex: 100, display: 'flex', @@ -65,12 +65,12 @@ const useStyles = makeStyles( title: { color: theme.palette.bursts.fontColor, wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + fontSize: theme.typography.h3.fontSize, marginBottom: 0, }, subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', + color: theme.palette.common.white, + opacity: 0.8, display: 'inline-block', // prevents margin collapse of adjacent siblings marginTop: theme.spacing(1), }, @@ -82,7 +82,6 @@ const useStyles = makeStyles( color: theme.palette.bursts.fontColor, }, breadcrumb: { - fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', color: theme.palette.bursts.fontColor, }, breadcrumbType: { diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index ed089ad6a1..3f15ae4ea6 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -293,11 +293,13 @@ export const SidebarItem = forwardRef((props, ref) => { type SidebarSearchFieldProps = { onSearch: (input: string) => void; to?: string; + icon?: IconComponent; }; export function SidebarSearchField(props: SidebarSearchFieldProps) { const [input, setInput] = useState(''); const classes = useStyles(); + const Icon = props.icon ? props.icon : SearchIcon; const search = () => { props.onSearch(input); @@ -329,7 +331,7 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) { return (
- + { userId: identity!.id, profile: profile!, getIdToken: () => { - return authApi.getBackstageIdentity().then(i => i!.idToken); + return authApi + .getBackstageIdentity() + .then(i => i!.token ?? i!.idToken); }, signOut: async () => { await authApi.signOut(); diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2765b02bca..27f3480930 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -404,23 +404,31 @@ export interface ElementCollection { }): ElementCollection; } -// @public -type Error_2 = { - name: string; - message: string; - stack?: string; -}; +// @public @deprecated (undocumented) +type Error_2 = ErrorApiError; export { Error_2 as Error }; // @public export type ErrorApi = { - post(error: Error_2, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; error$(): Observable_2<{ - error: Error_2; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; }; +// @public +export type ErrorApiError = { + name: string; + message: string; + stack?: string; +}; + +// @public +export type ErrorApiErrorContext = { + hidden?: boolean; +}; + // @public export const errorApiRef: ApiRef; @@ -431,10 +439,8 @@ export type ErrorBoundaryFallbackProps = { resetError: () => void; }; -// @public -export type ErrorContext = { - hidden?: boolean; -}; +// @public @deprecated (undocumented) +export type ErrorContext = ErrorApiErrorContext; // @public export type Extension = { @@ -703,14 +709,6 @@ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; -// @public -export type RouteOptions = { - exact?: boolean; -}; - -// @public -export type RoutePath = string; - // @public export type RouteRef = { $$routeRefType: 'absolute'; diff --git a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts index 820660ac52..45ee0901c8 100644 --- a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts @@ -23,22 +23,34 @@ import { Observable } from '@backstage/types'; * * @public */ -export type Error = { +export type ErrorApiError = { name: string; message: string; stack?: string; }; +/** + * @public + * @deprecated Use ErrorApiError instead + */ +export type Error = ErrorApiError; + /** * Provides additional information about an error that was posted to the application. * * @public */ -export type ErrorContext = { +export type ErrorApiErrorContext = { // If set to true, this error should not be displayed to the user. Defaults to false. hidden?: boolean; }; +/** + * @public + * @deprecated Use ErrorApiErrorContext instead + */ +export type ErrorContext = ErrorApiErrorContext; + /** * The error API is used to report errors to the app, and display them to the user. * @@ -62,12 +74,15 @@ export type ErrorApi = { /** * Post an error for handling by the application. */ - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; /** * Observe errors posted by other parts of the application. */ - error$(): Observable<{ error: Error; context?: ErrorContext }>; + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }>; }; /** diff --git a/packages/core-plugin-api/src/plugin/index.ts b/packages/core-plugin-api/src/plugin/index.ts index 0cb9a2aede..d3272607ef 100644 --- a/packages/core-plugin-api/src/plugin/index.ts +++ b/packages/core-plugin-api/src/plugin/index.ts @@ -25,6 +25,4 @@ export type { PluginConfig, PluginHooks, PluginOutput, - RouteOptions, - RoutePath, } from './types'; diff --git a/packages/core-plugin-api/src/plugin/types.ts b/packages/core-plugin-api/src/plugin/types.ts index 192e771092..aeb7037c51 100644 --- a/packages/core-plugin-api/src/plugin/types.ts +++ b/packages/core-plugin-api/src/plugin/types.ts @@ -17,23 +17,6 @@ import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; -/** - * Route configuration. - * - * @public - */ -export type RouteOptions = { - // Whether the route path must match exactly, defaults to true. - exact?: boolean; -}; - -/** - * Type alias for paths. - * - * @public - */ -export type RoutePath = string; - /** * Replace with using {@link RouteRef}s. * diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 2820d41ed6..7c1feec683 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -23,6 +23,7 @@ "scripts": { "build": "backstage-cli build --outputs cjs", "lint": "backstage-cli lint", + "test": "backstage-cli test", "clean": "backstage-cli clean", "start": "nodemon --" }, @@ -40,6 +41,7 @@ "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", "@types/recursive-readdir": "^2.2.0", + "mock-fs": "^5.1.1", "ts-node": "^10.0.0" }, "peerDependencies": { diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts new file mode 100644 index 0000000000..09099e2d15 --- /dev/null +++ b/packages/create-app/src/createApp.test.ts @@ -0,0 +1,83 @@ +/* + * 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 inquirer from 'inquirer'; +import mockFs from 'mock-fs'; +import path from 'path'; +import { Command } from 'commander'; +import * as tasks from './lib/tasks'; +import createApp from './createApp'; + +jest.mock('./lib/tasks'); + +beforeAll(() => { + mockFs({ + [`${__dirname}/package.json`]: '', // required by `findPaths(__dirname)` + 'templates/': mockFs.load(path.resolve(__dirname, '../templates/')), + }); +}); + +afterAll(() => { + mockFs.restore(); +}); + +const promptMock = jest.spyOn(inquirer, 'prompt'); +const checkPathExistsMock = jest.spyOn(tasks, 'checkPathExistsTask'); +const templatingMock = jest.spyOn(tasks, 'templatingTask'); +const checkAppExistsMock = jest.spyOn(tasks, 'checkAppExistsTask'); +const createTemporaryAppFolderMock = jest.spyOn( + tasks, + 'createTemporaryAppFolderTask', +); +const moveAppMock = jest.spyOn(tasks, 'moveAppTask'); +const buildAppMock = jest.spyOn(tasks, 'buildAppTask'); + +describe('command entrypoint', () => { + beforeEach(() => { + promptMock.mockResolvedValueOnce({ + name: 'MyApp', + dbType: 'PostgreSQL', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should call expected tasks with no path option', async () => { + const cmd = {} as unknown as Command; + await createApp(cmd); + expect(checkAppExistsMock).toHaveBeenCalled(); + expect(createTemporaryAppFolderMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(moveAppMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should call expected tasks with path option', async () => { + const cmd = { path: 'myDirectory' } as unknown as Command; + await createApp(cmd); + expect(checkPathExistsMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should not call `buildAppTask` when `skipInstall` is supplied', async () => { + const cmd = { skipInstall: true } as unknown as Command; + await createApp(cmd); + expect(buildAppMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index d9719742fb..8823e6c858 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -14,85 +14,21 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { promisify } from 'util'; import chalk from 'chalk'; import { Command } from 'commander'; import inquirer, { Answers, Question } from 'inquirer'; -import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import { findPaths } from '@backstage/cli-common'; import os from 'os'; -import { Task, templatingTask } from './lib/tasks'; - -const exec = promisify(execCb); - -async function checkAppExists(rootDir: string, name: string) { - await Task.forItem('checking', name, async () => { - const destination = resolvePath(rootDir, name); - - if (await fs.pathExists(destination)) { - const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); - throw new Error( - `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, - ); - } - }); -} - -async function checkPathExists(path: string) { - await Task.forItem('checking', path, async () => { - try { - await fs.mkdirs(path); - } catch (error) { - // will fail if a file already exists at given `path` - throw new Error(`Failed to create app directory: ${error.message}`); - } - }); -} - -async function createTemporaryAppFolder(tempDir: string) { - await Task.forItem('creating', 'temporary directory', async () => { - try { - await fs.mkdir(tempDir); - } catch (error) { - throw new Error(`Failed to create temporary app directory, ${error}`); - } - }); -} - -async function buildApp(appDir: string) { - const runCmd = async (cmd: string) => { - await Task.forItem('executing', cmd, async () => { - process.chdir(appDir); - - await exec(cmd).catch(error => { - process.stdout.write(error.stderr); - process.stdout.write(error.stdout); - throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); - }); - }); - }; - - await runCmd('yarn install'); - await runCmd('yarn tsc'); -} - -async function moveApp(tempDir: string, destination: string, id: string) { - await Task.forItem('moving', id, async () => { - await fs - .move(tempDir, destination) - .catch(error => { - throw new Error( - `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, - ); - }) - .finally(() => { - // remove temporary files on both success and failure - fs.removeSync(tempDir); - }); - }); -} +import { + Task, + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './lib/tasks'; export default async (cmd: Command): Promise => { /* eslint-disable-next-line no-restricted-syntax */ @@ -143,7 +79,7 @@ export default async (cmd: Command): Promise => { // Template directly to specified path Task.section('Checking that supplied path exists'); - await checkPathExists(appDir); + await checkPathExistsTask(appDir); Task.section('Preparing files'); await templatingTask(templateDir, cmd.path, answers); @@ -151,21 +87,21 @@ export default async (cmd: Command): Promise => { // Template to temporary location, and then move files Task.section('Checking if the directory is available'); - await checkAppExists(paths.targetDir, answers.name); + await checkAppExistsTask(paths.targetDir, answers.name); Task.section('Creating a temporary app directory'); - await createTemporaryAppFolder(tempDir); + await createTemporaryAppFolderTask(tempDir); Task.section('Preparing files'); await templatingTask(templateDir, tempDir, answers); Task.section('Moving to final location'); - await moveApp(tempDir, appDir, answers.name); + await moveAppTask(tempDir, appDir, answers.name); } if (!cmd.skipInstall) { Task.section('Building the app'); - await buildApp(appDir); + await buildAppTask(appDir); } Task.log(); diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts new file mode 100644 index 0000000000..d69526cb49 --- /dev/null +++ b/packages/create-app/src/lib/tasks.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import child_process from 'child_process'; +import path from 'path'; +import { + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './tasks'; + +jest.mock('child_process'); + +beforeEach(() => { + mockFs({ + 'projects/my-module.ts': '', + 'projects/dir/my-file.txt': '', + 'tmp/mockApp/.gitignore': '', + 'tmp/mockApp/package.json': '', + 'tmp/mockApp/packages/app/package.json': '', + // load templates into mock filesystem + 'templates/': mockFs.load(path.resolve(__dirname, '../../templates/')), + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe('checkAppExistsTask', () => { + it('should do nothing if the directory does not exist', async () => { + const dir = 'projects/'; + const name = 'MyNewApp'; + await expect(checkAppExistsTask(dir, name)).resolves.not.toThrow(); + }); + + it('should throw an error when a file of the same name exists', async () => { + const dir = 'projects/'; + const name = 'my-module.ts'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); + + it('should throw an error when a directory of the same name exists', async () => { + const dir = 'projects/'; + const name = 'dir'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('checkPathExistsTask', () => { + it('should create a directory at the given path', async () => { + const appDir = 'projects/newProject'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should do nothing if a directory of the same name exists', async () => { + const appDir = 'projects/dir'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should fail if a file of the same name exists', async () => { + await expect(checkPathExistsTask('projects/my-module.ts')).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('createTemporaryAppFolderTask', () => { + it('should create a directory at a given path', async () => { + const tempDir = 'projects/tmpFolder'; + await expect(createTemporaryAppFolderTask(tempDir)).resolves.not.toThrow(); + expect(fs.existsSync(tempDir)).toBe(true); + }); + + it('should fail if a directory of the same name exists', async () => { + const tempDir = 'projects/dir'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); + + it('should fail if a file of the same name exists', async () => { + const tempDir = 'projects/dir/my-file.txt'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); +}); + +describe('buildAppTask', () => { + it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => { + const mockChdir = jest.spyOn(process, 'chdir'); + const mockExec = child_process.exec as unknown as jest.MockedFunction< + ( + command: string, + callback: (error: null, stdout: string, stderr: string) => void, + ) => void + >; + + // requires callback implementation to support `promisify` wrapper + // https://stackoverflow.com/a/60579617/10044859 + mockExec.mockImplementation((_command, callback) => { + callback(null, 'standard out', 'standard error'); + }); + + const appDir = 'projects/dir'; + await expect(buildAppTask(appDir)).resolves.not.toThrow(); + expect(mockChdir).toBeCalledTimes(2); + expect(mockChdir).toHaveBeenNthCalledWith(1, appDir); + expect(mockChdir).toHaveBeenNthCalledWith(2, appDir); + expect(mockExec).toBeCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + 'yarn install', + expect.any(Function), + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + 'yarn tsc', + expect.any(Function), + ); + }); + + it('should fail if project directory does not exist', async () => { + const appDir = 'projects/missingProject'; + await expect(buildAppTask(appDir)).rejects.toThrow( + 'no such file or directory', + ); + }); +}); + +describe('moveAppTask', () => { + const tempDir = 'tmp/mockApp/'; + const id = 'myApp'; + + it('should move all files in the temp dir to the target dir', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('projects/mockApp/.gitignore')).toBe(true); + expect(fs.existsSync('projects/mockApp/package.json')).toBe(true); + expect(fs.existsSync('projects/mockApp/packages/app/package.json')).toBe( + true, + ); + }); + + it('should fail to move files if destination already exists', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow( + 'dest already exists', + ); + }); + + it('should remove temporary files if move succeeded', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); + + it('should remove temporary files if move failed', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow(); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); +}); + +describe('templatingTask', () => { + it('should generate a project populating context parameters', async () => { + const templateDir = 'templates/default-app'; + const destinationDir = 'templatedApp'; + const context = { + name: 'SuperCoolBackstageInstance', + dbTypeSqlite: true, + }; + await templatingTask(templateDir, destinationDir, context); + expect(fs.existsSync('templatedApp/package.json')).toBe(true); + expect(fs.existsSync('templatedApp/.dockerignore')).toBe(true); + // catalog was populated with `context.name` + expect( + fs.readFileSync('templatedApp/catalog-info.yaml', 'utf-8'), + ).toContain('name: SuperCoolBackstageInstance'); + // backend dependencies include `sqlite3` from `context.SQLite` + expect( + fs.readFileSync('templatedApp/packages/backend/package.json', 'utf-8'), + ).toContain('"sqlite3"'); + }); +}); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 19f0ed66b8..3961e84753 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -18,11 +18,19 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; -import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { + basename, + dirname, + resolve as resolvePath, + relative as relativePath, +} from 'path'; +import { exec as execCb } from 'child_process'; import { packageVersions } from './versions'; +import { promisify } from 'util'; const TASK_NAME_MAX_LENGTH = 14; +const exec = promisify(execCb); export class Task { static log(name: string = '') { @@ -65,6 +73,13 @@ export class Task { } } +/** + * Generate a templated backstage project + * + * @param templateDir - location containing template files + * @param destinationDir - location to save templated project + * @param context - template parameters + */ export async function templatingTask( templateDir: string, destinationDir: string, @@ -75,7 +90,10 @@ export async function templatingTask( }); for (const file of files) { - const destinationFile = file.replace(templateDir, destinationDir); + const destinationFile = resolvePath( + destinationDir, + relativePath(templateDir, file), + ); await fs.ensureDir(dirname(destinationFile)); if (file.endsWith('.hbs')) { @@ -116,3 +134,105 @@ export async function templatingTask( } } } + +/** + * Verify that application target does not already exist + * + * @param rootDir - The directory to create application folder `name` + * @param name - The specified name of the application + * @Throws Error - If directory with name of `destination` already exists + */ +export async function checkAppExistsTask(rootDir: string, name: string) { + await Task.forItem('checking', name, async () => { + const destination = resolvePath(rootDir, name); + + if (await fs.pathExists(destination)) { + const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); + throw new Error( + `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, + ); + } + }); +} + +/** + * Verify that application `path` exists, otherwise create the directory + * + * @param {string} path - target to create directory + * @throws {Error} if `path` is a file, or `fs.mkdir` fails + */ +export async function checkPathExistsTask(path: string) { + await Task.forItem('checking', path, async () => { + try { + await fs.mkdirs(path); + } catch (error) { + // will fail if a file already exists at given `path` + throw new Error(`Failed to create app directory: ${error.message}`); + } + }); +} + +/** + * Create a folder to store templated files + * + * @param {string} tempDir - target temporary directory + * @throws {Error} if `fs.mkdir` fails + */ +export async function createTemporaryAppFolderTask(tempDir: string) { + await Task.forItem('creating', 'temporary directory', async () => { + try { + await fs.mkdir(tempDir); + } catch (error) { + throw new Error(`Failed to create temporary app directory, ${error}`); + } + }); +} + +/** + * Run `yarn install` and `run tsc` in application directory + * + * @param {string} appDir - location of application to build + */ +export async function buildAppTask(appDir: string) { + const runCmd = async (cmd: string) => { + await Task.forItem('executing', cmd, async () => { + process.chdir(appDir); + await exec(cmd).catch(error => { + process.stdout.write(error.stderr); + process.stdout.write(error.stdout); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + }); + }; + + await runCmd('yarn install'); + await runCmd('yarn tsc'); +} + +/** + * Move temporary directory to destination application folder + * + * @param {string} tempDir source path to copy files from + * @param {string} destination target path to copy files + * @param {string} id + * @throws {Error} if `fs.move` fails + */ +export async function moveAppTask( + tempDir: string, + destination: string, + id: string, +) { + await Task.forItem('moving', id, async () => { + await fs + .move(tempDir, destination) + .catch(error => { + throw new Error( + `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, + ); + }) + .finally(() => { + // remove temporary files on both success and failure + fs.removeSync(tempDir); + }); + }); +} diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 25577df1dd..b44e68374d 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -45,7 +45,7 @@ "@backstage/search-common": "^0.2.1", "@backstage/integration": "^0.6.9", "@google-cloud/storage": "^5.6.0", - "@trendyol-js/openstack-swift-sdk": "^0.0.4", + "@trendyol-js/openstack-swift-sdk": "^0.0.5", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "express": "^4.17.1", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 1f7afa0855..ff88f12939 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -45,10 +45,12 @@ const createPublisherFromConfig = ({ bucketName = 'bucketName', bucketRootPath = '/', legacyUseCaseSensitiveTripletPaths = false, + sse, }: { bucketName?: string; bucketRootPath?: string; legacyUseCaseSensitiveTripletPaths?: boolean; + sse?: string; } = {}) => { const mockConfig = new ConfigReader({ techdocs: { @@ -62,6 +64,7 @@ const createPublisherFromConfig = ({ }, bucketName, bucketRootPath, + sse, }, }, legacyUseCaseSensitiveTripletPaths, @@ -171,6 +174,13 @@ describe('AwsS3Publish', () => { expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); + it('should publish a directory when sse is specified', async () => { + const publisher = createPublisherFromConfig({ + sse: 'aws:kms', + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); + it('should fail to publish a directory', async () => { const wrongPathToGeneratedDirectory = path.join( rootDir, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 5ebc2aab77..abfa8ddd24 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -62,6 +62,7 @@ export class AwsS3Publish implements PublisherBase { private readonly legacyPathCasing: boolean; private readonly logger: Logger; private readonly bucketRootPath: string; + private readonly sse?: 'aws:kms' | 'AES256'; constructor(options: { storageClient: aws.S3; @@ -69,12 +70,14 @@ export class AwsS3Publish implements PublisherBase { legacyPathCasing: boolean; logger: Logger; bucketRootPath: string; + sse?: 'aws:kms' | 'AES256'; }) { this.storageClient = options.storageClient; this.bucketName = options.bucketName; this.legacyPathCasing = options.legacyPathCasing; this.logger = options.logger; this.bucketRootPath = options.bucketRootPath; + this.sse = options.sse; } static fromConfig(config: Config, logger: Logger): PublisherBase { @@ -92,6 +95,11 @@ export class AwsS3Publish implements PublisherBase { config.getOptionalString('techdocs.publisher.awsS3.bucketRootPath') || '', ); + const sse = config.getOptionalString('techdocs.publisher.awsS3.sse') as + | 'aws:kms' + | 'AES256' + | undefined; + // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. // 1. AWS environment variables // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html @@ -138,6 +146,7 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, legacyPathCasing, logger, + sse, }); } @@ -208,6 +217,7 @@ export class AwsS3Publish implements PublisherBase { async publish({ entity, directory }: PublishRequest): Promise { const useLegacyPathCasing = this.legacyPathCasing; const bucketRootPath = this.bucketRootPath; + const sse = this.sse; // First, try to retrieve a list of all individual files currently existing let existingFiles: string[] = []; @@ -250,7 +260,8 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, ), Body: fileStream, - }; + ...(sse && { ServerSideEncryption: sse }), + } as aws.S3.PutObjectRequest; return this.storageClient.upload(params).promise(); }, diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index a463157552..7b623933e3 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -24,7 +24,11 @@ import { SwiftClient } from '@trendyol-js/openstack-swift-sdk'; import { NotFound } from '@trendyol-js/openstack-swift-sdk/lib/types'; import { Stream, Readable } from 'stream'; import { Logger } from 'winston'; -import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { + getFileTreeRecursively, + getHeadersForFileExtension, + lowerCaseEntityTripletInStoragePath, +} from './helpers'; import { PublisherBase, PublishRequest, @@ -289,4 +293,67 @@ export class OpenStackSwiftPublish implements PublisherBase { return false; } } + + async migrateDocsCase({ + removeOriginal = false, + concurrency = 25, + }): Promise { + // Iterate through every file in the root of the publisher. + const allObjects = await this.getAllObjectsFromContainer(); + const limiter = createLimiter(concurrency); + await Promise.all( + allObjects.map(f => + limiter(async file => { + let newPath; + try { + newPath = lowerCaseEntityTripletInStoragePath(file); + } catch (e) { + assertError(e); + this.logger.warn(e.message); + return; + } + + // If all parts are already lowercase, ignore. + if (file === newPath) { + return; + } + + try { + this.logger.verbose(`Migrating ${file} to ${newPath}`); + await this.storageClient.copy( + this.containerName, + file, + this.containerName, + newPath, + ); + if (removeOriginal) { + await this.storageClient.delete(this.containerName, file); + } + } catch (e) { + assertError(e); + this.logger.warn(`Unable to migrate ${file}: ${e.message}`); + } + }, f), + ), + ); + } + + /** + * Returns a list of all object keys from the configured container. + */ + protected async getAllObjectsFromContainer( + { prefix } = { prefix: '' }, + ): Promise { + let objects: string[] = []; + const OSS_MAX_LIMIT = Math.pow(2, 31) - 1; + + const allObjects = await this.storageClient.list( + this.containerName, + prefix, + OSS_MAX_LIMIT, + ); + objects = allObjects.map((object: any) => object.name); + + return objects; + } } diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index c4936f6d96..62a8d9f601 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -7,7 +7,8 @@ import { AnalyticsApi } from '@backstage/core-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { ReactElement } from 'react'; @@ -27,8 +28,8 @@ export type CollectedLogs = { // @public export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; // @public @deprecated (undocumented) @@ -109,13 +110,13 @@ export class MockErrorApi implements ErrorApi { constructor(options?: MockErrorApiOptions); // (undocumented) error$(): Observable<{ - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; // (undocumented) getErrors(): ErrorWithContext[]; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; // (undocumented) waitForError(pattern: RegExp, timeoutMs?: number): Promise; } diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 87601e29e2..96918b4e47 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; /** @@ -27,12 +31,12 @@ export type MockErrorApiOptions = { }; /** - * ErrorWithContext contains error and ErrorContext + * ErrorWithContext contains error and ErrorApiErrorContext * @public */ export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; type Waiter = { @@ -59,7 +63,7 @@ export class MockErrorApi implements ErrorApi { constructor(private readonly options: MockErrorApiOptions = {}) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (this.options.collect) { this.errors.push({ error, context }); @@ -76,7 +80,10 @@ export class MockErrorApi implements ErrorApi { throw new Error(`MockErrorApi received unexpected error, ${error}`); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }> { return nullObservable; } diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 27b629cc07..dfb3a0a79a 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -22,7 +22,7 @@ import { OAuthHandlers } from './types'; const mockResponseData = { providerInfo: { accessToken: 'ACCESS_TOKEN', - idToken: 'ID_TOKEN', + token: 'ID_TOKEN', expiresInSeconds: 10, scope: 'email', }, @@ -216,7 +216,7 @@ describe('OAuthAdapter', () => { ...mockResponseData, backstageIdentity: { id: mockResponseData.backstageIdentity.id, - idToken: 'my-id-token', + token: 'my-id-token', }, }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index a5128711bc..24b9a0f210 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -233,10 +233,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return; } - if (!identity.idToken) { - identity.idToken = await this.options.tokenIssuer.issueToken({ + if (!(identity.token || identity.idToken)) { + identity.token = await this.options.tokenIssuer.issueToken({ claims: { sub: identity.id }, }); + } else if (!identity.token && identity.idToken) { + identity.token = identity.idToken; } } diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 143e31bc21..6660912dda 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -1290,6 +1290,7 @@ export class NextCatalogBuilder { locationService: LocationService; router: Router; }>; + getDefaultProcessors(): CatalogProcessor[]; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen replaceEntityPolicies(policies: EntityPolicy[]): NextCatalogBuilder; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index 835c1dc700..bbda6f070c 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -265,7 +265,8 @@ export class NextCatalogBuilder { * Sets what entity processors to use. These are responsible for reading, * parsing, and processing entities before they are persisted in the catalog. * - * This function replaces the default set of processors; use with care. + * This function replaces the default set of processors, consider using with + * {@link NextCatalogBuilder#getDefaultProcessors}; use with care. * * @param processors One or more processors */ @@ -275,6 +276,30 @@ export class NextCatalogBuilder { return this; } + /** + * Returns the default list of entity processors. These are responsible for reading, + * parsing, and processing entities before they are persisted in the catalog. Changing + * the order of processing can give more control to custom processors. + * + * Consider using with {@link NextCatalogBuilder#replaceProcessors} + * + */ + getDefaultProcessors(): CatalogProcessor[] { + const { config, logger, reader } = this.env; + const integrations = ScmIntegrations.fromConfig(config); + + return [ + new FileReaderProcessor(), + BitbucketDiscoveryProcessor.fromConfig(config, { logger }), + GithubDiscoveryProcessor.fromConfig(config, { logger }), + GithubOrgReaderProcessor.fromConfig(config, { logger }), + GitLabDiscoveryProcessor.fromConfig(config, { logger }), + new UrlReaderProcessor({ reader, logger }), + CodeOwnersProcessor.fromConfig(config, { logger, reader }), + new AnnotateLocationEntityProcessor({ integrations }), + ]; + } + /** * Sets up the catalog to use a custom parser for entity data. * @@ -392,7 +417,7 @@ export class NextCatalogBuilder { } private buildProcessors(): CatalogProcessor[] { - const { config, logger, reader } = this.env; + const { config, reader } = this.env; const integrations = ScmIntegrations.fromConfig(config); this.checkDeprecatedReaderProcessors(); @@ -416,16 +441,7 @@ export class NextCatalogBuilder { // These are only added unless the user replaced them all if (!this.processorsReplace) { - processors.push( - new FileReaderProcessor(), - BitbucketDiscoveryProcessor.fromConfig(config, { logger }), - GithubDiscoveryProcessor.fromConfig(config, { logger }), - GithubOrgReaderProcessor.fromConfig(config, { logger }), - GitLabDiscoveryProcessor.fromConfig(config, { logger }), - new UrlReaderProcessor({ reader, logger }), - CodeOwnersProcessor.fromConfig(config, { logger, reader }), - new AnnotateLocationEntityProcessor({ integrations }), - ); + processors.push(...this.getDefaultProcessors()); } // Add the ones (if any) that the user added diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 68777d4109..7e44ae4efb 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -18,8 +18,6 @@ import { Entity, ENTITY_DEFAULT_NAMESPACE, LOCATION_ANNOTATION, - RELATION_CONSUMES_API, - RELATION_PROVIDES_API, stringifyEntityRef, } from '@backstage/catalog-model'; import { @@ -36,7 +34,6 @@ import { import { catalogApiRef, getEntityMetadataEditUrl, - getEntityRelations, getEntitySourceLocation, useEntity, } from '@backstage/plugin-catalog-react'; @@ -51,7 +48,6 @@ import { import CachedIcon from '@material-ui/icons/Cached'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; -import ExtensionIcon from '@material-ui/icons/Extension'; import React, { useCallback } from 'react'; import { viewTechDocRouteRef } from '../../routes'; import { AboutContent } from './AboutContent'; @@ -95,16 +91,6 @@ export function AboutCard({ variant }: AboutCardProps) { scmIntegrationsApi, ); const entityMetadataEditUrl = getEntityMetadataEditUrl(entity); - const providesApiRelations = getEntityRelations( - entity, - RELATION_PROVIDES_API, - ); - const consumesApiRelations = getEntityRelations( - entity, - RELATION_CONSUMES_API, - ); - const hasApis = - providesApiRelations.length > 0 || consumesApiRelations.length > 0; const viewInSource: IconLinkVerticalProps = { label: 'View Source', @@ -126,13 +112,6 @@ export function AboutCard({ variant }: AboutCardProps) { name: entity.metadata.name, }), }; - const viewApi: IconLinkVerticalProps = { - title: hasApis ? '' : 'No APIs available', - label: 'View API', - disabled: !hasApis, - icon: , - href: 'api', - }; let cardClass = ''; if (variant === 'gridItem') { @@ -181,9 +160,7 @@ export function AboutCard({ variant }: AboutCardProps) { } - subheader={ - - } + subheader={} /> diff --git a/plugins/cost-insights/src/testUtils/testUtils.ts b/plugins/cost-insights/src/testUtils/testUtils.ts index 2c6f6b5ef2..e0ed1aa933 100644 --- a/plugins/cost-insights/src/testUtils/testUtils.ts +++ b/plugins/cost-insights/src/testUtils/testUtils.ts @@ -61,7 +61,7 @@ export function aggregationFor( const days = DateTime.fromISO(endDate).diff( DateTime.fromISO(inclusiveStartDateOf(duration, inclusiveEndDate)), 'days', - ); + ).days; function nextDelta(): number { const varianceFromBaseline = 0.15; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 4befec5a81..755b435149 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -144,6 +144,7 @@ describe('fetch:template', () => { name: 'test-project', count: 1234, itemList: ['first', 'second', 'third'], + showDummyFile: false, }, }); @@ -163,6 +164,10 @@ describe('fetch:template', () => { }, '.${{ values.name }}': '${{ values.itemList | dump }}', 'a-binary-file.png': aBinaryFile, + '{% if values.showDummyFile %}dummy-file.txt{% else %}{% endif %}': + 'dummy file', + '${{ "dummy-file2.txt" if values.showDummyFile else "" }}': + 'some dummy file', }, }); @@ -181,6 +186,18 @@ describe('fetch:template', () => { ); }); + it('skips empty filename', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty filename syntax #2', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file2.txt`), + ).resolves.toEqual(false); + }); + it('copies files with no templating in names or content successfully', async () => { await expect( fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index bb568c25e6..47bfcccfdf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -241,6 +241,11 @@ export function createFetchTemplateAction(options: { localOutputPath = templater.renderString(localOutputPath, context); } const outputPath = resolvePath(outputDir, localOutputPath); + // variables have been expanded to make an empty file name + // this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip + if (outputDir === outputPath) { + continue; + } if (!renderContents && !extension) { ctx.logger.info( diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1d8bc4a0ea..594a8e6423 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -52,7 +52,7 @@ "git-url-parse": "^11.6.0", "humanize-duration": "^3.25.1", "immer": "^9.0.1", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "luxon": "^2.0.2", "qs": "^6.9.4", diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 0023a4b757..da3cd34830 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -121,6 +121,14 @@ export interface Config { * @visibility backend */ s3ForcePathStyle?: boolean; + + /** + * (Optional) AWS Server Side Encryption + * Defaults to undefined. + * If not set, encrypted buckets will fail to publish. + * https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + */ + sse?: 'aws:kms' | 'AES256'; }; } | { diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index e84454ae54..77e69995ae 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -39,7 +39,7 @@ export const DocsCardGrid = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; return ( diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index f210e5f678..0e64c2e2e8 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -56,7 +56,7 @@ export const DocsTable = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 4e12ac18d1..c0b0c99b59 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -214,6 +214,16 @@ export const useTechDocsReaderDom = (): Element | null => { .md-typeset .admonition, .md-typeset details { font-size: 1rem; } + + /* style the checkmarks of the task list */ + .md-typeset .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .md-typeset .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + /**/ + @media screen and (max-width: 76.1875em) { .md-nav { background-color: ${theme.palette.background.default}; @@ -293,8 +303,8 @@ export const useTechDocsReaderDom = (): Element | null => { --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); } :host { - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); } `, }), @@ -305,9 +315,11 @@ export const useTechDocsReaderDom = (): Element | null => { namespace, scmIntegrationsApi, techdocsStorageApi, + theme.palette.action.disabledBackground, theme.palette.background.default, theme.palette.background.paper, theme.palette.primary.main, + theme.palette.success.main, theme.palette.text.primary, theme.typography.fontFamily, ], diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index 653e85a541..a801f2e807 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -37,10 +37,11 @@ export const ProviderSettingsItem: ({ // @public (undocumented) export const Router: ({ providerSettings }: Props) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "SettingsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Settings" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const Settings: () => JSX.Element; +export const Settings: (props: SettingsProps) => JSX.Element; // Warning: (ae-missing-release-tag) "UserSettingsAppearanceCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/user-settings/src/components/Settings.tsx b/plugins/user-settings/src/components/Settings.tsx index deb8dddf58..43a00ee9cc 100644 --- a/plugins/user-settings/src/components/Settings.tsx +++ b/plugins/user-settings/src/components/Settings.tsx @@ -18,13 +18,14 @@ import React from 'react'; import SettingsIcon from '@material-ui/icons/Settings'; import { settingsRouteRef } from '../plugin'; import { SidebarItem } from '@backstage/core-components'; +import { IconComponent } from '@backstage/core-plugin-api'; -export const Settings = () => { - return ( - - ); +type SettingsProps = { + icon?: IconComponent; +}; + +export const Settings = (props: SettingsProps) => { + const Icon = props.icon ? props.icon : SettingsIcon; + + return ; }; diff --git a/yarn.lock b/yarn.lock index e18f6fedb2..adf51ccb32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5303,10 +5303,10 @@ react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -"@rollup/plugin-commonjs@^17.1.0": - version "17.1.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" - integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.1" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee" + integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -6714,10 +6714,10 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@trendyol-js/openstack-swift-sdk@^0.0.4": - version "0.0.4" - resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.4.tgz#570c6ab950319156c175ace005b4fb4d9f895d47" - integrity sha512-9YKOjov+V+yzptei6+B9QPuC5pOMTBTg/NQpb1ZbxvlOaYpWU4HHpSH2BkIFYZ8vYyAfzFNG1T2rjpQ2ZQDUtQ== +"@trendyol-js/openstack-swift-sdk@^0.0.5": + version "0.0.5" + resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.5.tgz#65be3c42b8dbafc57f2f2a46c327e2ad51e5a70e" + integrity sha512-KS5nz0cvd35UUyMzhZm+btGV4prtA1KNE7CCMOGBdVxoMGl06Qidli3HgHoc2I9jLPmky1SPp5yzQUwrsyWa0g== dependencies: agentkeepalive "^4.1.4" axios "^0.21.1" @@ -13688,9 +13688,9 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-cypress@^2.10.3: - version "2.11.3" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.3.tgz#54ee4067aa8192aa62810cd35080eb577e191ab7" - integrity sha512-hOoAid+XNFtpvOzZSNWP5LDrQBEJwbZwjib4XJ1KcRYKjeVj0mAmPmucG4Egli4j/aruv+Ow/acacoloWWCl9Q== + version "2.12.1" + resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.12.1.tgz#9aeee700708ca8c058e00cdafe215199918c2632" + integrity sha512-c2W/uPADl5kospNDihgiLc7n87t5XhUbFDoTl6CfVkmG+kDAb5Ux10V9PoLPu9N+r7znpc+iQlcmAqT1A/89HA== dependencies: globals "^11.12.0" @@ -18083,9 +18083,9 @@ joycon@^2.2.5: integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== js-base64@^3.6.0: - version "3.6.1" - resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.6.1.tgz#555aae398b74694b4037af1f8a5a6209d170efbe" - integrity sha512-Frdq2+tRRGLQUIQOgsIGSCd1VePCS2fsddTG5dTCqR0JHgltXWfsxnY0gIXPoMeRmdom6Oyq+UMOFg5suduOjQ== + version "3.7.2" + resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745" + integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ== js-cookie@^2.2.1: version "2.2.1" @@ -18305,10 +18305,10 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b" - integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" @@ -20635,6 +20635,11 @@ mock-fs@^5.1.0: resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.0.tgz#a9aebd4e6d74a626f84b86eae8a372bd061754e8" integrity sha512-wXdQ2nIk81TYIGLphUnbXl8akQpjb9ItfZefMcTxZcoe+djMkd5POU8fQdSEErxVAeT4CgDHWveYquys4H6Cmw== +mock-fs@^5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7" + integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -23137,9 +23142,9 @@ prettier@^1.19.1: integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== prettier@^2.2.1: - version "2.4.0" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz#85bdfe0f70c3e777cf13a4ffff39713ca6f64cba" - integrity sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ== + version "2.4.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" + integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== prettier@~2.2.1: version "2.2.1"