From 4891a8f2e6f8e3c0993b6826676ea97bf0592895 Mon Sep 17 00:00:00 2001 From: Dede Hamzah Date: Wed, 3 Nov 2021 16:08:27 +0700 Subject: [PATCH 001/118] Add Props Icon for Sidebar Item SidebarSearchField and Settings Signed-off-by: Dede Hamzah --- .../src/layout/Sidebar/Items.tsx | 4 +++- .../user-settings/src/components/Settings.tsx | 17 +++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 883e0395cd..75317ed9ac 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 (
- + { - return ( - - ); +type SettingsProps = { + icon?: IconComponent; +}; + +export const Settings = (props: SettingsProps) => { + const Icon = props.icon ? props.icon : SettingsIcon; + + return ; }; From 274a4fc6335eff6091e995254636c98b7650d79e Mon Sep 17 00:00:00 2001 From: Dede Hamzah Date: Wed, 3 Nov 2021 16:11:23 +0700 Subject: [PATCH 002/118] add changeset Signed-off-by: Dede Hamzah --- .changeset/khaki-rice-kick.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/khaki-rice-kick.md 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 From b90fc74d7099b815d8a22eab4f04bbf106a65b33 Mon Sep 17 00:00:00 2001 From: Bryce Larson Date: Thu, 4 Nov 2021 20:08:58 +1100 Subject: [PATCH 003/118] feat: Add getDefaultProcessors to CatalogBuilder Signed-off-by: Bryce Larson --- .changeset/violet-panthers-care.md | 5 +++ .../src/service/NextCatalogBuilder.ts | 38 +++++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 .changeset/violet-panthers-care.md 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/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index 835c1dc700..5e0bcd9a75 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 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. * @@ -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 From b8b67b574011074720b9f7d252b9ee2005b9a97b Mon Sep 17 00:00:00 2001 From: Gabriele Mambrini Date: Fri, 5 Nov 2021 09:52:50 +0100 Subject: [PATCH 004/118] Add allowed paths to backend.reading.allow Signed-off-by: Gabriele Mambrini --- .../src/reading/FetchUrlReader.test.ts | 7 +++++++ .../backend-common/src/reading/FetchUrlReader.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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..1ae9677a85 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 { normalize as normalizePath } from 'path'; /** * A UrlReader that does a plain fetch of the URL. @@ -39,18 +40,28 @@ 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 = normalizePath(url.pathname); + return paths.some(path => targetPath.startsWith(path)); + } + : (_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(); From bfc837a97b5cd16916ac241067df90e62a149479 Mon Sep 17 00:00:00 2001 From: Gabriele Mambrini Date: Fri, 5 Nov 2021 09:53:45 +0100 Subject: [PATCH 005/118] Update docs for allowed paths in backend.reading.allow Signed-off-by: Gabriele Mambrini --- docs/features/software-catalog/descriptor-format.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 1daada3a06f511ee31614822e7d00cd4f88fd0fe Mon Sep 17 00:00:00 2001 From: Gabriele Mambrini Date: Fri, 5 Nov 2021 10:00:17 +0100 Subject: [PATCH 006/118] Added changeset Signed-off-by: Gabriele Mambrini --- .changeset/nasty-impalas-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nasty-impalas-travel.md 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 From 23348dd5864a2427c40288b6cf30a103bb267556 Mon Sep 17 00:00:00 2001 From: Bryce Larson <85923137+bryce-od@users.noreply.github.com> Date: Fri, 5 Nov 2021 10:25:28 +1100 Subject: [PATCH 007/118] Update plugins/catalog-backend/src/service/NextCatalogBuilder.ts Co-authored-by: Jussi Hallila Signed-off-by: Bryce Larson --- plugins/catalog-backend/src/service/NextCatalogBuilder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index 5e0bcd9a75..bbda6f070c 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -281,7 +281,7 @@ export class NextCatalogBuilder { * 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 with {@link NextCatalogBuilder#replaceProcessors} + * Consider using with {@link NextCatalogBuilder#replaceProcessors} * */ getDefaultProcessors(): CatalogProcessor[] { @@ -417,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(); From 0514f55ff1a12000c2f29014bd98f5ab8bcf1780 Mon Sep 17 00:00:00 2001 From: Gabriele Mambrini Date: Mon, 8 Nov 2021 17:56:07 +0100 Subject: [PATCH 008/118] Update config schema Signed-off-by: Gabriele Mambrini --- packages/backend-common/config.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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[]; }>; }; From f14ac64f2e8446844a99f639727c3dd3feae125c Mon Sep 17 00:00:00 2001 From: Gabriele Mambrini Date: Mon, 8 Nov 2021 18:06:53 +0100 Subject: [PATCH 009/118] Use posix normaliser for URL paths Signed-off-by: Gabriele Mambrini --- packages/backend-common/src/reading/FetchUrlReader.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 1ae9677a85..19bf5fb140 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -24,7 +24,7 @@ import { SearchResponse, UrlReader, } from './types'; -import { normalize as normalizePath } from 'path'; +import path from 'path'; /** * A UrlReader that does a plain fetch of the URL. @@ -52,8 +52,10 @@ export class FetchUrlReader implements UrlReader { const paths = allowConfig.getOptionalStringArray('paths'); const checkPath = paths ? (url: URL) => { - const targetPath = normalizePath(url.pathname); - return paths.some(path => targetPath.startsWith(path)); + const targetPath = path.posix.normalize(url.pathname); + return paths.some(allowedPath => + targetPath.startsWith(allowedPath), + ); } : (_url: URL) => true; const host = allowConfig.getString('host'); From 41e5f28ab77b8f0b60d2e8d6aba62bcfccc4dd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Tue, 9 Nov 2021 12:36:18 +0300 Subject: [PATCH 010/118] OSS lowercase migration added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- packages/techdocs-common/package.json | 2 +- .../src/stages/publish/openStackSwift.ts | 66 +++++++++++++++++-- yarn.lock | 64 +++++++++++++++--- 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 152b9b04f1..2bc3e80f26 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/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index a463157552..83e17fa466 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -24,7 +24,7 @@ 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, @@ -77,7 +77,7 @@ export class OpenStackSwiftPublish implements PublisherBase { } catch (error) { throw new Error( "Since techdocs.publisher.type is set to 'openStackSwift' in your app config, " + - 'techdocs.publisher.openStackSwift.containerName is required.', + 'techdocs.publisher.openStackSwift.containerName is required.', ); } @@ -115,9 +115,9 @@ export class OpenStackSwiftPublish implements PublisherBase { } this.logger.error( `Could not retrieve metadata about the OpenStack Swift container ${this.containerName}. ` + - 'Make sure the container exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + 'Make sure the container exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', ); return { isAvailable: false, @@ -289,4 +289,60 @@ 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[] = []; + let allObjects: any; + const OSS_MAX_LIMIT = Math.pow(2, 31) - 1; + + allObjects = await this.storageClient.list(this.containerName, prefix, OSS_MAX_LIMIT) + objects = allObjects.map((object: any) => object = object.name) + + return objects; + } } diff --git a/yarn.lock b/yarn.lock index 42c43fe4b7..915dfdfbda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4613,7 +4613,7 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" -"@mswjs/cookies@^0.1.6": +"@mswjs/cookies@^0.1.5", "@mswjs/cookies@^0.1.6": version "0.1.6" resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.6.tgz#176f77034ab6d7373ae5c94bcbac36fee8869249" integrity sha512-A53XD5TOfwhpqAmwKdPtg1dva5wrng2gH5xMvklzbd9WLTSVU953eCRa8rtrrm6G7Cy60BOGsBRN89YQK0mlKA== @@ -4621,6 +4621,17 @@ "@types/set-cookie-parser" "^2.4.0" set-cookie-parser "^2.4.6" +"@mswjs/interceptors@^0.10.0": + version "0.10.0" + resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.10.0.tgz#f5aad03c2c0591d164e3ed178b21942f1c2f8061" + integrity sha512-/M0GGpid5q2EDI+Keas1sLYF3VZFXHDE5gCmX/jHdp+OJFruVNca3PUk7A8KnGdPpuycZogdPsmRBSOXwjyA7A== + dependencies: + "@open-draft/until" "^1.0.3" + debug "^4.3.0" + headers-utils "^3.0.2" + strict-event-emitter "^0.2.0" + xmldom "^0.6.0" + "@mswjs/interceptors@^0.12.6": version "0.12.7" resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909" @@ -6695,10 +6706,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" @@ -6912,7 +6923,7 @@ dependencies: "@types/express" "*" -"@types/cookie@^0.4.1": +"@types/cookie@^0.4.0", "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== @@ -12586,7 +12597,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.3.2, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: +debug@4, debug@4.3.2, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.0, debug@^4.3.1, debug@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== @@ -15722,6 +15733,11 @@ graphql@^15.3.0: resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.1.tgz#f2f84415d8985e7b84731e7f3536f8bb9d383aad" integrity sha512-FeTRX67T3LoE3LWAxxOlW2K3Bz+rMYAC18rRguK4wgXaTZMiJwSUwDmPFo3UadAKbzirKIg5Qy+sNJXbpPRnQw== +graphql@^15.4.0: + version "15.7.2" + resolved "https://registry.npmjs.org/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef" + integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A== + graphql@^15.5.1: version "15.6.1" resolved "https://registry.npmjs.org/graphql/-/graphql-15.6.1.tgz#9125bdf057553525da251e19e96dab3d3855ddfc" @@ -16649,7 +16665,7 @@ inquirer@^8.0.0: strip-ansi "^6.0.0" through "^2.3.6" -inquirer@^8.1.1: +inquirer@^8.1.0, inquirer@^8.1.1: version "8.2.0" resolved "https://registry.npmjs.org/inquirer/-/inquirer-8.2.0.tgz#f44f008dd344bbfc4b30031f45d984e034a3ac3a" integrity sha512-0crLweprevJ02tTuA6ThpoAERAGyVILC4sS74uib58Xf/zSr1/ZWtmm7D5CI+bSQEaA04f0K7idaHpQbSWgiVQ== @@ -20678,6 +20694,31 @@ msal@^1.0.2: dependencies: tslib "^1.9.3" +msw@^0.29.0: + version "0.29.0" + resolved "https://registry.npmjs.org/msw/-/msw-0.29.0.tgz#7242d575cb01db0c925241587df1fc2b79230d78" + integrity sha512-C/wz1d5uAEZRvAPAYrXG1rwLxXl0+BOs+JPrCzasoABZW3ATwS6ifSze+/DAgA93e9M86RXwvy6yDtZeZWmCFQ== + dependencies: + "@mswjs/cookies" "^0.1.5" + "@mswjs/interceptors" "^0.10.0" + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + "@types/inquirer" "^7.3.1" + "@types/js-levenshtein" "^1.1.0" + chalk "^4.1.1" + chokidar "^3.4.2" + cookie "^0.4.1" + graphql "^15.4.0" + headers-utils "^3.0.2" + inquirer "^8.1.0" + js-levenshtein "^1.1.6" + node-fetch "^2.6.1" + node-match-path "^0.6.3" + statuses "^2.0.0" + strict-event-emitter "^0.2.0" + type-fest "^1.1.3" + yargs "^17.0.1" + msw@^0.35.0: version "0.35.0" resolved "https://registry.npmjs.org/msw/-/msw-0.35.0.tgz#18a4ceb6c822ef226a30421d434413bc45030d38" @@ -27640,7 +27681,7 @@ type-fest@^0.8.1: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^1.2.2: +type-fest@^1.1.3, type-fest@^1.2.2: version "1.4.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== @@ -29155,6 +29196,11 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmldom@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz#43a96ecb8beece991cef382c08397d82d4d0c46f" + integrity sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg== + xpath@0.0.32: version "0.0.32" resolved "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" From c79b30420b462cb39bf8ef1a412072d53aeb13a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Tue, 9 Nov 2021 12:36:47 +0300 Subject: [PATCH 011/118] lowercase routing issue fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- plugins/techdocs/src/home/components/DocsCardGrid.tsx | 2 +- plugins/techdocs/src/home/components/DocsTable.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index e84454ae54..eadfd0c8b6 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..9d88f50ab3 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; From c5e5b7815588a986d4232103b112f6a7542b3f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Tue, 9 Nov 2021 12:51:40 +0300 Subject: [PATCH 012/118] changeset added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- .changeset/chatty-months-report.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/chatty-months-report.md diff --git a/.changeset/chatty-months-report.md b/.changeset/chatty-months-report.md new file mode 100644 index 0000000000..a0925be239 --- /dev/null +++ b/.changeset/chatty-months-report.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': minor +'@backstage/plugin-techdocs': minor +--- + +OpenStack Swift Migration Support added From 884c6d0bc5c3e2cca96a7f46671b3ccc16bc50c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Tue, 9 Nov 2021 13:08:34 +0300 Subject: [PATCH 013/118] prettier runned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- .../src/stages/publish/openStackSwift.ts | 35 ++++++++++++------- .../src/home/components/DocsCardGrid.tsx | 2 +- .../src/home/components/DocsTable.tsx | 2 +- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 83e17fa466..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, lowerCaseEntityTripletInStoragePath } from './helpers'; +import { + getFileTreeRecursively, + getHeadersForFileExtension, + lowerCaseEntityTripletInStoragePath, +} from './helpers'; import { PublisherBase, PublishRequest, @@ -77,7 +81,7 @@ export class OpenStackSwiftPublish implements PublisherBase { } catch (error) { throw new Error( "Since techdocs.publisher.type is set to 'openStackSwift' in your app config, " + - 'techdocs.publisher.openStackSwift.containerName is required.', + 'techdocs.publisher.openStackSwift.containerName is required.', ); } @@ -115,9 +119,9 @@ export class OpenStackSwiftPublish implements PublisherBase { } this.logger.error( `Could not retrieve metadata about the OpenStack Swift container ${this.containerName}. ` + - 'Make sure the container exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + 'Make sure the container exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', ); return { isAvailable: false, @@ -290,7 +294,6 @@ export class OpenStackSwiftPublish implements PublisherBase { } } - async migrateDocsCase({ removeOriginal = false, concurrency = 25, @@ -317,7 +320,12 @@ export class OpenStackSwiftPublish implements PublisherBase { try { this.logger.verbose(`Migrating ${file} to ${newPath}`); - await this.storageClient.copy(this.containerName, file, this.containerName, newPath); + await this.storageClient.copy( + this.containerName, + file, + this.containerName, + newPath, + ); if (removeOriginal) { await this.storageClient.delete(this.containerName, file); } @@ -331,17 +339,20 @@ export class OpenStackSwiftPublish implements PublisherBase { } /** - * Returns a list of all object keys from the configured container. - */ + * Returns a list of all object keys from the configured container. + */ protected async getAllObjectsFromContainer( { prefix } = { prefix: '' }, ): Promise { let objects: string[] = []; - let allObjects: any; const OSS_MAX_LIMIT = Math.pow(2, 31) - 1; - allObjects = await this.storageClient.list(this.containerName, prefix, OSS_MAX_LIMIT) - objects = allObjects.map((object: any) => object = object.name) + const allObjects = await this.storageClient.list( + this.containerName, + prefix, + OSS_MAX_LIMIT, + ); + objects = allObjects.map((object: any) => object.name); return objects; } diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index eadfd0c8b6..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("en-US"); + : (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 9d88f50ab3..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("en-US"); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; From 740e06ca28eeb7ca66828d61af6565fb8843b597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Wed, 10 Nov 2021 13:41:46 +0300 Subject: [PATCH 014/118] yarn lock update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- yarn.lock | 66 +++++++++---------------------------------------------- 1 file changed, 10 insertions(+), 56 deletions(-) diff --git a/yarn.lock b/yarn.lock index 915dfdfbda..c360ca8ec3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4613,7 +4613,7 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" -"@mswjs/cookies@^0.1.5", "@mswjs/cookies@^0.1.6": +"@mswjs/cookies@^0.1.6": version "0.1.6" resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.6.tgz#176f77034ab6d7373ae5c94bcbac36fee8869249" integrity sha512-A53XD5TOfwhpqAmwKdPtg1dva5wrng2gH5xMvklzbd9WLTSVU953eCRa8rtrrm6G7Cy60BOGsBRN89YQK0mlKA== @@ -4621,17 +4621,6 @@ "@types/set-cookie-parser" "^2.4.0" set-cookie-parser "^2.4.6" -"@mswjs/interceptors@^0.10.0": - version "0.10.0" - resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.10.0.tgz#f5aad03c2c0591d164e3ed178b21942f1c2f8061" - integrity sha512-/M0GGpid5q2EDI+Keas1sLYF3VZFXHDE5gCmX/jHdp+OJFruVNca3PUk7A8KnGdPpuycZogdPsmRBSOXwjyA7A== - dependencies: - "@open-draft/until" "^1.0.3" - debug "^4.3.0" - headers-utils "^3.0.2" - strict-event-emitter "^0.2.0" - xmldom "^0.6.0" - "@mswjs/interceptors@^0.12.6": version "0.12.7" resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909" @@ -6706,10 +6695,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.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== +"@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== dependencies: agentkeepalive "^4.1.4" axios "^0.21.1" @@ -6923,7 +6912,7 @@ dependencies: "@types/express" "*" -"@types/cookie@^0.4.0", "@types/cookie@^0.4.1": +"@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== @@ -12597,7 +12586,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.3.2, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.0, debug@^4.3.1, debug@^4.3.2: +debug@4, debug@4.3.2, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== @@ -15733,11 +15722,6 @@ graphql@^15.3.0: resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.1.tgz#f2f84415d8985e7b84731e7f3536f8bb9d383aad" integrity sha512-FeTRX67T3LoE3LWAxxOlW2K3Bz+rMYAC18rRguK4wgXaTZMiJwSUwDmPFo3UadAKbzirKIg5Qy+sNJXbpPRnQw== -graphql@^15.4.0: - version "15.7.2" - resolved "https://registry.npmjs.org/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef" - integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A== - graphql@^15.5.1: version "15.6.1" resolved "https://registry.npmjs.org/graphql/-/graphql-15.6.1.tgz#9125bdf057553525da251e19e96dab3d3855ddfc" @@ -16665,7 +16649,7 @@ inquirer@^8.0.0: strip-ansi "^6.0.0" through "^2.3.6" -inquirer@^8.1.0, inquirer@^8.1.1: +inquirer@^8.1.1: version "8.2.0" resolved "https://registry.npmjs.org/inquirer/-/inquirer-8.2.0.tgz#f44f008dd344bbfc4b30031f45d984e034a3ac3a" integrity sha512-0crLweprevJ02tTuA6ThpoAERAGyVILC4sS74uib58Xf/zSr1/ZWtmm7D5CI+bSQEaA04f0K7idaHpQbSWgiVQ== @@ -20694,31 +20678,6 @@ msal@^1.0.2: dependencies: tslib "^1.9.3" -msw@^0.29.0: - version "0.29.0" - resolved "https://registry.npmjs.org/msw/-/msw-0.29.0.tgz#7242d575cb01db0c925241587df1fc2b79230d78" - integrity sha512-C/wz1d5uAEZRvAPAYrXG1rwLxXl0+BOs+JPrCzasoABZW3ATwS6ifSze+/DAgA93e9M86RXwvy6yDtZeZWmCFQ== - dependencies: - "@mswjs/cookies" "^0.1.5" - "@mswjs/interceptors" "^0.10.0" - "@open-draft/until" "^1.0.3" - "@types/cookie" "^0.4.0" - "@types/inquirer" "^7.3.1" - "@types/js-levenshtein" "^1.1.0" - chalk "^4.1.1" - chokidar "^3.4.2" - cookie "^0.4.1" - graphql "^15.4.0" - headers-utils "^3.0.2" - inquirer "^8.1.0" - js-levenshtein "^1.1.6" - node-fetch "^2.6.1" - node-match-path "^0.6.3" - statuses "^2.0.0" - strict-event-emitter "^0.2.0" - type-fest "^1.1.3" - yargs "^17.0.1" - msw@^0.35.0: version "0.35.0" resolved "https://registry.npmjs.org/msw/-/msw-0.35.0.tgz#18a4ceb6c822ef226a30421d434413bc45030d38" @@ -27681,7 +27640,7 @@ type-fest@^0.8.1: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^1.1.3, type-fest@^1.2.2: +type-fest@^1.2.2: version "1.4.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== @@ -29196,11 +29155,6 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xmldom@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz#43a96ecb8beece991cef382c08397d82d4d0c46f" - integrity sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg== - xpath@0.0.32: version "0.0.32" resolved "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" @@ -29495,4 +29449,4 @@ zwitch@^1.0.0: zwitch@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" - integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== + integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== \ No newline at end of file From 38d6df6bb9ecdce798ea9033f5b68490dcfe9823 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 4 Nov 2021 16:05:15 +0100 Subject: [PATCH 015/118] feat(catalog): remove 'View Api' link from AboutCard Signed-off-by: Dominik Schwank --- .changeset/big-months-float.md | 6 +++++ .../src/components/AboutCard/AboutCard.tsx | 25 +------------------ 2 files changed, 7 insertions(+), 24 deletions(-) create mode 100644 .changeset/big-months-float.md 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/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={} /> From 55ced4d6288ff08919882846adf79a9700a746e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Nov 2021 04:08:39 +0000 Subject: [PATCH 016/118] build(deps): bump @rollup/plugin-commonjs from 17.1.0 to 21.0.1 Bumps [@rollup/plugin-commonjs](https://github.com/rollup/plugins/tree/HEAD/packages/commonjs) from 17.1.0 to 21.0.1. - [Release notes](https://github.com/rollup/plugins/releases) - [Changelog](https://github.com/rollup/plugins/blob/master/packages/commonjs/CHANGELOG.md) - [Commits](https://github.com/rollup/plugins/commits/commonjs-v21.0.1/packages/commonjs) --- updated-dependencies: - dependency-name: "@rollup/plugin-commonjs" dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- packages/cli/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 23a2bef027..89a210290b 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", diff --git a/yarn.lock b/yarn.lock index 42c43fe4b7..5fc4c40365 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5309,10 +5309,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" From 2163e83fa22e12b2f1727b6b9cbc49c6bd42028e Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Thu, 28 Oct 2021 19:57:23 -0400 Subject: [PATCH 017/118] Refactor create-app tasks and introduce regression tests - Tasks were moved from the script entrypoint to the lib directory - Doc comments were added for each task function defined in `src/lib/tasks.ts` - The `yarn test` script was added to package.json - Unit tests were written for each task -- verying file operations using fs-mock Signed-off-by: Colton Padden --- .changeset/many-sloths-cross.md | 5 + packages/create-app/package.json | 2 + packages/create-app/src/createApp.ts | 92 ++-------- packages/create-app/src/lib/tasks.test.ts | 208 ++++++++++++++++++++++ packages/create-app/src/lib/tasks.ts | 114 +++++++++++- 5 files changed, 342 insertions(+), 79 deletions(-) create mode 100644 .changeset/many-sloths-cross.md create mode 100644 packages/create-app/src/lib/tasks.test.ts 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/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.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..0abbb8e0a2 --- /dev/null +++ b/packages/create-app/src/lib/tasks.test.ts @@ -0,0 +1,208 @@ +/* + * 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, { ChildProcess } 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 = jest.spyOn(child_process, 'exec'); + + // requires callback implementation to support `promisify` wrapper + // https://stackoverflow.com/a/60579617/10044859 + mockExec.mockImplementation((_: string, callback?: any): ChildProcess => { + callback(null, 'stdout', 'stderr'); + return; + }); + + 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..0b3ca63728 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -18,11 +18,14 @@ 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 } 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 +68,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, @@ -116,3 +126,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); + }); + }); +} From 1d666cb67e28900c3ada3d1937708df7cdd1f1df Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Fri, 29 Oct 2021 10:44:32 -0400 Subject: [PATCH 018/118] Add regression tests to createApp entrypoint Ensure that appropriate tasks are called depending on inquirer prompt answers, and command-line argument options (--path, and --skip-install). Signed-off-by: Colton Padden --- packages/create-app/src/createApp.test.ts | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/create-app/src/createApp.test.ts diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts new file mode 100644 index 0000000000..b2d946f307 --- /dev/null +++ b/packages/create-app/src/createApp.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { Command } from 'commander'; +import * as tasks from './lib/tasks'; +import createApp from './createApp'; + +jest.mock('./lib/tasks'); + +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(); + }); +}); From bd90ded8b7d93c140ade4802cdacda9ccc22a465 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Fri, 29 Oct 2021 11:32:23 -0400 Subject: [PATCH 019/118] Add type annotations for mocked child_process.exec Signed-off-by: Colton Padden --- packages/create-app/src/lib/tasks.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts index 0abbb8e0a2..d69526cb49 100644 --- a/packages/create-app/src/lib/tasks.test.ts +++ b/packages/create-app/src/lib/tasks.test.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import child_process, { ChildProcess } from 'child_process'; +import child_process from 'child_process'; import path from 'path'; import { buildAppTask, @@ -114,22 +114,24 @@ describe('createTemporaryAppFolderTask', () => { describe('buildAppTask', () => { it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => { const mockChdir = jest.spyOn(process, 'chdir'); - const mockExec = jest.spyOn(child_process, 'exec'); + 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((_: string, callback?: any): ChildProcess => { - callback(null, 'stdout', 'stderr'); - return; + 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, From 1b3fab7193b7b6c7bda547c12ed763ac144f210b Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Fri, 29 Oct 2021 11:45:01 -0400 Subject: [PATCH 020/118] Use mock-fs during createApp to prevent filesystem modifications Signed-off-by: Colton Padden --- packages/create-app/src/createApp.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts index b2d946f307..7b3b8f124c 100644 --- a/packages/create-app/src/createApp.test.ts +++ b/packages/create-app/src/createApp.test.ts @@ -15,12 +15,25 @@ */ 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({ + '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'); From 71e7b0ee92cd8ab27288f8e550810d0f48b147c6 Mon Sep 17 00:00:00 2001 From: Andrew Thauer Date: Thu, 28 Oct 2021 10:08:08 -0400 Subject: [PATCH 021/118] refactor: reunification of techdocs-cli and backstage repos Signed-off-by: Andrew Thauer --- .changeset/mean-elephants-serve.md | 9 + .github/CODEOWNERS | 2 + .github/styles/vocab.txt | 1 + .github/workflows/techdocs-e2e.yml | 42 +++ .../techdocs/techdocs-cli-serve-preview.png | Bin 0 -> 284502 bytes docs/features/techdocs/cli.md | 241 ++++++++++++++++++ microsite/sidebars.json | 1 + mkdocs.yml | 2 +- package.json | 1 + packages/embedded-techdocs-app/.eslintrc.js | 3 + .../embedded-techdocs-app/app-config.dev.yaml | 7 + .../embedded-techdocs-app/app-config.yaml | 10 + packages/embedded-techdocs-app/cypress.json | 5 + .../cypress/.eslintrc.json | 21 ++ .../cypress/integration/app.js | 22 ++ packages/embedded-techdocs-app/package.json | 64 +++++ .../public/android-chrome-192x192.png | Bin 0 -> 13599 bytes .../public/apple-touch-icon.png | Bin 0 -> 12619 bytes .../public/favicon-16x16.png | Bin 0 -> 883 bytes .../public/favicon-32x32.png | Bin 0 -> 1686 bytes .../embedded-techdocs-app/public/favicon.ico | Bin 0 -> 15086 bytes .../embedded-techdocs-app/public/index.html | 66 +++++ .../public/manifest.json | 15 ++ .../embedded-techdocs-app/public/robots.txt | 2 + .../public/safari-pinned-tab.svg | 1 + .../embedded-techdocs-app/src/App.test.tsx | 42 +++ packages/embedded-techdocs-app/src/App.tsx | 68 +++++ packages/embedded-techdocs-app/src/apis.ts | 199 +++++++++++++++ .../src/components/Root/LogoFull.tsx | 46 ++++ .../src/components/Root/LogoIcon.tsx | 47 ++++ .../src/components/Root/Root.tsx | 80 ++++++ .../src/components/Root/index.ts | 17 ++ .../components/TechDocsPage/TechDocsPage.tsx | 54 ++++ .../src/components/TechDocsPage/index.ts | 16 ++ packages/embedded-techdocs-app/src/index.tsx | 22 ++ packages/embedded-techdocs-app/src/plugins.ts | 17 ++ .../embedded-techdocs-app/src/setupTests.ts | 17 ++ packages/techdocs-cli/.eslintrc.js | 11 + packages/techdocs-cli/CHANGELOG.md | 70 +++++ packages/techdocs-cli/README.md | 68 +++++ packages/techdocs-cli/bin/techdocs-cli | 36 +++ packages/techdocs-cli/package.json | 70 +++++ packages/techdocs-cli/scripts/build.sh | 37 +++ .../src/commands/generate/generate.ts | 99 +++++++ packages/techdocs-cli/src/commands/index.ts | 238 +++++++++++++++++ .../src/commands/migrate/migrate.ts | 55 ++++ .../src/commands/publish/publish.ts | 52 ++++ .../techdocs-cli/src/commands/serve/mkdocs.ts | 71 ++++++ .../techdocs-cli/src/commands/serve/serve.ts | 126 +++++++++ packages/techdocs-cli/src/e2e.test.ts | 131 ++++++++++ .../techdocs-cli/src/fixture/docs/README.md | 1 + packages/techdocs-cli/src/fixture/mkdocs.yml | 8 + packages/techdocs-cli/src/index.ts | 29 +++ .../src/lib/PublisherConfig.test.ts | 141 ++++++++++ .../techdocs-cli/src/lib/PublisherConfig.ts | 163 ++++++++++++ packages/techdocs-cli/src/lib/httpServer.ts | 100 ++++++++ .../techdocs-cli/src/lib/mkdocsServer.test.ts | 90 +++++++ packages/techdocs-cli/src/lib/mkdocsServer.ts | 59 +++++ packages/techdocs-cli/src/lib/run.ts | 106 ++++++++ packages/techdocs-cli/src/lib/utility.ts | 54 ++++ scripts/api-extractor.ts | 1 + yarn.lock | 136 +++++++++- 62 files changed, 3089 insertions(+), 3 deletions(-) create mode 100644 .changeset/mean-elephants-serve.md create mode 100644 .github/workflows/techdocs-e2e.yml create mode 100644 docs/assets/features/techdocs/techdocs-cli-serve-preview.png create mode 100644 docs/features/techdocs/cli.md create mode 100644 packages/embedded-techdocs-app/.eslintrc.js create mode 100644 packages/embedded-techdocs-app/app-config.dev.yaml create mode 100644 packages/embedded-techdocs-app/app-config.yaml create mode 100644 packages/embedded-techdocs-app/cypress.json create mode 100644 packages/embedded-techdocs-app/cypress/.eslintrc.json create mode 100644 packages/embedded-techdocs-app/cypress/integration/app.js create mode 100644 packages/embedded-techdocs-app/package.json create mode 100644 packages/embedded-techdocs-app/public/android-chrome-192x192.png create mode 100644 packages/embedded-techdocs-app/public/apple-touch-icon.png create mode 100644 packages/embedded-techdocs-app/public/favicon-16x16.png create mode 100644 packages/embedded-techdocs-app/public/favicon-32x32.png create mode 100644 packages/embedded-techdocs-app/public/favicon.ico create mode 100644 packages/embedded-techdocs-app/public/index.html create mode 100644 packages/embedded-techdocs-app/public/manifest.json create mode 100644 packages/embedded-techdocs-app/public/robots.txt create mode 100644 packages/embedded-techdocs-app/public/safari-pinned-tab.svg create mode 100644 packages/embedded-techdocs-app/src/App.test.tsx create mode 100644 packages/embedded-techdocs-app/src/App.tsx create mode 100644 packages/embedded-techdocs-app/src/apis.ts create mode 100644 packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx create mode 100644 packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx create mode 100644 packages/embedded-techdocs-app/src/components/Root/Root.tsx create mode 100644 packages/embedded-techdocs-app/src/components/Root/index.ts create mode 100644 packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx create mode 100644 packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts create mode 100644 packages/embedded-techdocs-app/src/index.tsx create mode 100644 packages/embedded-techdocs-app/src/plugins.ts create mode 100644 packages/embedded-techdocs-app/src/setupTests.ts create mode 100644 packages/techdocs-cli/.eslintrc.js create mode 100644 packages/techdocs-cli/CHANGELOG.md create mode 100644 packages/techdocs-cli/README.md create mode 100755 packages/techdocs-cli/bin/techdocs-cli create mode 100644 packages/techdocs-cli/package.json create mode 100755 packages/techdocs-cli/scripts/build.sh create mode 100644 packages/techdocs-cli/src/commands/generate/generate.ts create mode 100644 packages/techdocs-cli/src/commands/index.ts create mode 100644 packages/techdocs-cli/src/commands/migrate/migrate.ts create mode 100644 packages/techdocs-cli/src/commands/publish/publish.ts create mode 100644 packages/techdocs-cli/src/commands/serve/mkdocs.ts create mode 100644 packages/techdocs-cli/src/commands/serve/serve.ts create mode 100644 packages/techdocs-cli/src/e2e.test.ts create mode 100644 packages/techdocs-cli/src/fixture/docs/README.md create mode 100644 packages/techdocs-cli/src/fixture/mkdocs.yml create mode 100644 packages/techdocs-cli/src/index.ts create mode 100644 packages/techdocs-cli/src/lib/PublisherConfig.test.ts create mode 100644 packages/techdocs-cli/src/lib/PublisherConfig.ts create mode 100644 packages/techdocs-cli/src/lib/httpServer.ts create mode 100644 packages/techdocs-cli/src/lib/mkdocsServer.test.ts create mode 100644 packages/techdocs-cli/src/lib/mkdocsServer.ts create mode 100644 packages/techdocs-cli/src/lib/run.ts create mode 100644 packages/techdocs-cli/src/lib/utility.ts diff --git a/.changeset/mean-elephants-serve.md b/.changeset/mean-elephants-serve.md new file mode 100644 index 0000000000..2f048f0715 --- /dev/null +++ b/.changeset/mean-elephants-serve.md @@ -0,0 +1,9 @@ +--- +'@techdocs/cli': patch +--- + +Reunifies the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo +code back into the main [backstage](https://github.com/backstage/backstage) repo +(see [7288](https://github.com/backstage/backstage/issues/7288)). The changes +include some internal refactoring that do not affect functionality beyond the +local development setup. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b637524a52..9be92d294e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,7 +18,9 @@ /plugins/techdocs-backend @backstage/techdocs-core /plugins/ilert @backstage/reviewers @yacut /plugins/home @backstage/techdocs-core +/packages/embedded-techdocs-app @backstage/techdocs-core /packages/search-common @backstage/techdocs-core +/packages/techdocs-cli @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7e478953bc..b6e18d7256 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -62,6 +62,7 @@ Debounce declaratively deduplicated deps +dependabot destructured dev devops diff --git a/.github/workflows/techdocs-e2e.yml b/.github/workflows/techdocs-e2e.yml new file mode 100644 index 0000000000..8f166515ff --- /dev/null +++ b/.github/workflows/techdocs-e2e.yml @@ -0,0 +1,42 @@ +name: Techdocs E2E Test + +on: + pull_request: + paths-ignore: + - '.changeset/**' + - 'contrib/**' + - 'docs/**' + - 'microsite/**' + +jobs: + verify: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + + - name: install dependencies + run: yarn install --frozen-lockfile + + - name: generate types + run: yarn tsc + + - name: build techdocs-cli + working-directory: packages/techdocs-cli + run: yarn build + + - name: Install mkdocs & techdocs-core + run: python -m pip install mkdocs-techdocs-core + + - name: techdocs-cli e2e test + working-directory: packages/techdocs-cli + run: yarn test:e2e diff --git a/docs/assets/features/techdocs/techdocs-cli-serve-preview.png b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bcd48982f4cab3b98d4baed577fbd8095ea6501d GIT binary patch literal 284502 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
mUT3GV~$qjS?FIb&f4k7nUG-bARtK|tJQ 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ℑ~RVJy`RccYRamHCpy9vUrMsyjDrJyBf3ixF4mO; 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
Sr&h0$a{_546R;@_q$iTYGu=8rRsg_ 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 The mkdocs docker container to use (default: "spotify/techdocs") + --no-docker Do not use Docker, use MkDocs executable in current user environment. + --mkdocs-port Port for MkDocs server to use (default: "8000") + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Generate TechDocs site from a documentation project + +```bash +techdocs-cli generate +``` + +Alias: `techdocs-cli build` + +The generate command uses the +[`@backstage/techdocs-common`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) +package from Backstage for consistency. A Backstage app can also generate and +publish TechDocs sites if `techdocs.builder` is set to `'local'` in +`app-config.yaml`. See +[configuration reference](https://backstage.io/docs/features/techdocs/configuration). + +By default, this command uses Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) to make +sure all the dependencies are installed. But it can be disabled using +`--no-docker` flag. + +Command reference: + +```bash +techdocs-cli generate --help +Usage: techdocs-cli generate|build [options] + +Generate TechDocs documentation site using MkDocs. + +Options: + --source-dir Source directory containing mkdocs.yml and docs/ directory. (default: ".") + --output-dir Output directory containing generated TechDocs site. (default: "./site/") + --docker-image The mkdocs docker container to use (default: "spotify/techdocs:v0.3.4") + --no-pull Do not pull the latest docker image + --no-docker Do not use Docker, use MkDocs executable and plugins in current user environment. + --techdocs-ref The repository hosting documentation source files e.g. + github:https://ghe.mycompany.net.com/org/repo. + This value is same as the backstage.io/techdocs-ref annotation of the corresponding + Backstage entity. + It is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml + if not found. + --etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored + in techdocs_metadata.json. + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Publish generated TechDocs sites + +```bash +techdocs-cli publish --publisher-type --storage-name --entity +``` + +After generating a TechDocs site using `techdocs-cli generate`, use the publish +command to upload the static generated files on a cloud storage (AWS/GCS) bucket +or (Azure) container which your Backstage app can read from. + +The value for `--entity` must be the Backstage entity which the generated +TechDocs site belongs to. You can find the values in your Entity's +`catalog-info.yaml` file. If namespace is missing in the `catalog-info.yaml`, +use `default`. The directory structure used in the storage bucket is +`namespace/kind/name/`. + +Note that the values are case-sensitive. An example for `--entity` is +`default/Component/`. + +Command reference: + +```bash +Usage: techdocs-cli publish [options] + +Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc. + +Options: + --publisher-type (Required always) awsS3 | googleGcs | azureBlobStorage + - same as techdocs.publisher.type in Backstage + app-config.yaml + --storage-name (Required always) In case of AWS/GCS, use the bucket + name. In case of Azure, use container name. Same as + techdocs.publisher.[TYPE].bucketName + --entity (Required always) Entity uid separated by / in + namespace/kind/name order (case-sensitive). Example: + default/Component/myEntity + --legacyUseCaseSensitiveTripletPaths Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). + Only use if your TechDocs backend is configured the same way + --azureAccountName (Required for Azure) specify when --publisher-type + azureBlobStorage + --azureAccountKey Azure Storage Account key to use for authentication. + If not specified, you must set AZURE_TENANT_ID, + AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment + variables. + --awsRoleArn Optional AWS ARN of role to be assumed. + --awsEndpoint Optional AWS endpoint to send requests to. + --awsS3ForcePathStyle Optional AWS S3 option to force path style. + --directory Path of the directory containing generated files to + publish (default: "./site/") + -h, --help display help for command +``` + +### Migrate content for case-insensitive access + +Prior to the beta version of TechDocs (`v[0.11.0]`), TechDocs were stored in +object storage using a case-sensitive entity triplet (e.g. +`default/API/name/index.html`). This resulted in a limitation where that exact +case was required in the Backstage URL in order to read/render TechDocs content. +As of `v[0.11.0]` of the TechDocs plugin, any case is allowed in the URL (e.g. +`default/api/name`), matching the behavior of the Catalog plugin. + +Backstage instances created with TechDocs `v[0.11.0]` or later do not need this +command. However, when upgrading to this version from an older version of +TechDocs, the `migrate` command can be used prior to deployment to ensure docs +remain accessible without having to rebuild all docs. + +Prior to upgrading to `v[0.11.0]` or greater, run this command to copy all +assets to their lower-case triplet equivalents like this: + +```bash +techdocs-cli migrate --publisher-type --storage-name --verbose +``` + +Once migrated and the upgraded version of the Backstage plugin has been +deployed, you can clean up the legacy, case-sensitive triplet files by +re-running the command with the `--removeOriginal` flag passed, which _moves_ +(rather than copies) the files. Note: this deletes files and is therefore a +destructive operation that should performed with caution. + +```bash +techdocs-cli migrate --publisher-type --storage-name --removeOriginal --verbose +``` + +Afterward, update your TechDocs CLI to `v[0.7.0]` to ensure further publishing +happens using a lower-case entity triplet. + +Note: arguments for this command largely match those of the `publish` command, +depending on your chosen storage provider. Run `techdocs-cli migrate --help` for +details. + +#### Authentication + +You need to make sure that your environment is able to authenticate with the +target cloud provider. `techdocs-cli` uses the official Node.js clients provided +by AWS (v2), Google Cloud and Azure. You can authenticate using environment +variables and/or by other means (`~/.aws/credentials`, `~/.config/gcloud` etc.) + +Refer to the Authentication section of the following documentation depending +upon your cloud storage provider - + +- [Google Cloud Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-google-gcs-bucket-with-techdocs) +- [AWS S3](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs) +- [Azure Blob Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-azure-blob-storage-container-with-techdocs) + +## Development + +You are welcome to contribute to TechDocs CLI to improve it and support new +features! See the project +[README](https://github.com/backstage/backstage/blob/main/src/packages/techdocs-cli/README.md) +for more information. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index ec5c5d8d68..a24d5897ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -105,6 +105,7 @@ "features/techdocs/configuration", "features/techdocs/using-cloud-storage", "features/techdocs/configuring-ci-cd", + "features/techdocs/cli", "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" diff --git a/mkdocs.yml b/mkdocs.yml index 8c7114abbc..48df25afef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,7 +61,6 @@ nav: - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Templates (Legacy): 'features/software-templates/legacy.md' - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' - - Backstage Search: - Overview: 'features/search/README.md' - Getting Started: 'features/search/getting-started.md' @@ -78,6 +77,7 @@ nav: - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' + - CLI: 'features/techdocs/cli.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' diff --git a/package.json b/package.json index 3e6d376c46..4fc9b2352d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", + "techdocs-cli:dev": "TECHDOCS_CLI_DEV_MODE=true packages/techdocs-cli/bin/techdocs-cli", "prepare": "husky install", "lock:check": "yarn-lock-check" }, diff --git a/packages/embedded-techdocs-app/.eslintrc.js b/packages/embedded-techdocs-app/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/embedded-techdocs-app/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml new file mode 100644 index 0000000000..c40c473aa2 --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -0,0 +1,7 @@ +# NOTE: This file is used for testing techdocs-cli locally + +backend: + baseUrl: http://localhost:7000 + +techdocs: + requestUrl: http://localhost:7000/api diff --git a/packages/embedded-techdocs-app/app-config.yaml b/packages/embedded-techdocs-app/app-config.yaml new file mode 100644 index 0000000000..56e63ff97c --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.yaml @@ -0,0 +1,10 @@ +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:3000 + +techdocs: + builder: 'external' + requestUrl: http://localhost:3000/api diff --git a/packages/embedded-techdocs-app/cypress.json b/packages/embedded-techdocs-app/cypress.json new file mode 100644 index 0000000000..5de7ebffea --- /dev/null +++ b/packages/embedded-techdocs-app/cypress.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:3001", + "fixturesFolder": false, + "pluginsFile": false +} diff --git a/packages/embedded-techdocs-app/cypress/.eslintrc.json b/packages/embedded-techdocs-app/cypress/.eslintrc.json new file mode 100644 index 0000000000..2b3a458b95 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "plugins": ["cypress"], + "extends": ["plugin:cypress/recommended"], + "rules": { + "jest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "cy.contains"] + } + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true, + "optionalDependencies": true, + "peerDependencies": true, + "bundledDependencies": true + } + ] + } +} diff --git a/packages/embedded-techdocs-app/cypress/integration/app.js b/packages/embedded-techdocs-app/cypress/integration/app.js new file mode 100644 index 0000000000..d31f7a7964 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/integration/app.js @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('App', () => { + it('should render the catalog', () => { + cy.visit('/'); + cy.contains('My Company Service Catalog'); + }); +}); diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json new file mode 100644 index 0000000000..3bd52f39e2 --- /dev/null +++ b/packages/embedded-techdocs-app/package.json @@ -0,0 +1,64 @@ +{ + "name": "embedded-techdocs-app", + "version": "0.0.0", + "private": true, + "bundled": true, + "dependencies": { + "@backstage/catalog-model": "^0.9.5", + "@backstage/cli": "^0.8.0", + "@backstage/config": "^0.1.10", + "@backstage/core-app-api": "^0.1.18", + "@backstage/core-components": "^0.7.1", + "@backstage/core-plugin-api": "^0.1.11", + "@backstage/integration-react": "^0.1.12", + "@backstage/plugin-catalog": "^0.7.2", + "@backstage/plugin-techdocs": "^0.12.3", + "@backstage/test-utils": "^0.1.19", + "@backstage/theme": "^0.2.11", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "history": "^5.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^089.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dom": "*", + "cross-env": "^7.0.0", + "cypress": "^7.3.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli app:serve --config ./app-config.yaml --config ./app-config.dev.yaml", + "build": "backstage-cli app:build --config ./app-config.yaml", + "clean": "backstage-cli clean", + "test": "backstage-cli test", + "lint": "backstage-cli lint", + "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", + "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "prettier": "@spotify/prettier-config", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/packages/embedded-techdocs-app/public/android-chrome-192x192.png b/packages/embedded-techdocs-app/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..eec0ae25b971cae8eb0033c9af7e0f676d1df663 GIT binary patch literal 13599 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 EANz~b2mk;8 literal 0 HcmV?d00001 diff --git a/packages/embedded-techdocs-app/public/apple-touch-icon.png b/packages/embedded-techdocs-app/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3158830ac778a62ff8f08da0e9eeee6e8ada8bfc GIT binary patch literal 12619 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 VSn|oqbSlsa22WQ%mvv4FO#qym7y1AI literal 0 HcmV?d00001 diff --git a/packages/embedded-techdocs-app/public/favicon-32x32.png b/packages/embedded-techdocs-app/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..c0915ece75949f3d917134f55193949927edc633 GIT binary patch literal 1686 zcmd5*YdF&j82@WYD9ojqT$Wo{SSXitp^eRqxy{@++t}P`atlKxQX0)YMm6_KR%p4F z$}J+6LMeH2OXWDHjylI#AJ2#L{k+fbeSgn;`91HK_szmP+3%H7kpcicc}5$z1N!Px-7om}akWbt233fdJ10ExN)z&Z~ATcQ$c2>@ad0I)&=0IMPZ zfJBuET&w|L$8)@+J4!Sl|Nk(stgJlvmnlJ&;dZvl8tO{Q%IJ*5_~q$1+xa={mdc0(T`t!w?RZl>^IxA*%bGu3bF$<*nzTrF> zoQ&9oiO|s=ORAp|1maSVle9kTR(cJ300u{&4iq#dewwjMjZ;umbia|8`fV}#)u6uZ z2{{=Vn6Y8_#3SbXb1ic-&s#nMteerJ5NT@9WJFf_0Z{rXvToRrSOV|5nT6}g)=)7clCVKtHJ1*VPs-V;@XTM)*hF8A>dvO zHk*Y?X8JaA;})mFp7;7TRRuk~8$R*Snh{9txf3X8a4%y!TuP;MHGAGHB;T*cTs()( zPBU^uyOrcpI-AkwlQc~ccBy9xRRz@U7IH^D@piFSO__BF#e(AN-^z2yOmV!NrfGJV z)K=@)T;PiA16X29aqmP@px{GT+&pJ*OCU>i#GE8dw3RuCdu^ zY|d<%y|L(_@zJ9#_m z=Lc$*iilj{bjSDeNH|lRP>yLE)Yq?l!XVsP?uHhR(xnf>5p}vuAuF-j7*tA0oTAH zhFP&ZPMN7z8!K6i|K8u7|AYJvRll0p!ov{KSr|$njU-`o&7fVZN5@WQ|g`Mlirnap=oY~b#iCdHeScp|7sct zd}FL!1NSuuCnVle?ygaP975V!)pzN#*|A{{1* z$fS~)G%HFBO+IIv4>f5HNGY_6SF`0>Gf0kWIFv^nU=X CgrE5U literal 0 HcmV?d00001 diff --git a/packages/embedded-techdocs-app/public/favicon.ico b/packages/embedded-techdocs-app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5e45e5dfbde6f39603d5be60d933c1af14dffb1e GIT binary patch literal 15086 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 D+pS%} literal 0 HcmV?d00001 diff --git a/packages/embedded-techdocs-app/public/index.html b/packages/embedded-techdocs-app/public/index.html new file mode 100644 index 0000000000..a6102f010d --- /dev/null +++ b/packages/embedded-techdocs-app/public/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + <%= app.title %> + + + +
+ + + diff --git a/packages/embedded-techdocs-app/public/manifest.json b/packages/embedded-techdocs-app/public/manifest.json new file mode 100644 index 0000000000..4a7c1b4ec4 --- /dev/null +++ b/packages/embedded-techdocs-app/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Backstage", + "name": "Backstage", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/embedded-techdocs-app/public/robots.txt b/packages/embedded-techdocs-app/public/robots.txt new file mode 100644 index 0000000000..01b0f9a107 --- /dev/null +++ b/packages/embedded-techdocs-app/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/embedded-techdocs-app/public/safari-pinned-tab.svg b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg new file mode 100644 index 0000000000..0f500b3002 --- /dev/null +++ b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg @@ -0,0 +1 @@ +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/embedded-techdocs-app/src/App.test.tsx b/packages/embedded-techdocs-app/src/App.test.tsx new file mode 100644 index 0000000000..a5a4374976 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderWithEffects } from '@backstage/test-utils'; +import App from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, + techdocs: { + storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = await renderWithEffects(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/packages/embedded-techdocs-app/src/App.tsx b/packages/embedded-techdocs-app/src/App.tsx new file mode 100644 index 0000000000..81a495ca33 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Navigate, Route } from 'react-router'; +import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + +import { + DefaultTechDocsHome, + TechDocsIndexPage, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { apis } from './apis'; +import { Root } from './components/Root'; +import { techDocsPage } from './components/TechDocsPage'; +import * as plugins from './plugins'; + +const app = createApp({ + apis, + plugins: Object.values(plugins), +}); + +const AppProvider = app.getProvider(); +const AppRouter = app.getRouter(); + +const routes = ( + + + {/* we need this route as TechDocs header links relies on it */} + } + /> + }> + + + } + > + {techDocsPage} + + +); + +const App = () => ( + + + {routes} + + +); + +export default App; diff --git a/packages/embedded-techdocs-app/src/apis.ts b/packages/embedded-techdocs-app/src/apis.ts new file mode 100644 index 0000000000..6234e3b719 --- /dev/null +++ b/packages/embedded-techdocs-app/src/apis.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + scmIntegrationsApiRef, + ScmIntegrationsApi, +} from '@backstage/integration-react'; +import { + AnyApiFactory, + configApiRef, + createApiFactory, + DiscoveryApi, + discoveryApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + SyncResult, + TechDocsApi, + techdocsApiRef, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs'; + +// TODO: Export type from plugin-techdocs and import this here +// import { ParsedEntityId } from '@backstage/plugin-techdocs' + +/** + * Note: Override TechDocs API to use local mkdocs server instead of techdocs-backend. + */ + +class TechDocsDevStorageApi implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + + async getEntityDocs(_entityId: EntityName, path: string) { + const apiOrigin = await this.getApiOrigin(); + // Irrespective of the entity, use mkdocs server to find the file for the path. + const url = `${apiOrigin}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + async syncEntityDocs(_: EntityName): Promise { + // this is just stub of this function as we don't need to check if docs are up to date, + // we always want to retrigger a new build + return 'cached'; + } + + // Used by transformer to modify the request to assets (CSS, Image) from inside the HTML. + async getBaseUrl( + oldBaseUrl: string, + _entityId: EntityName, + path: string, + ): Promise { + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${path}`).toString(); + } +} + +class TechDocsDevApi implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getEntityMetadata(_entityId: any) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'local', + }, + spec: { + owner: 'test', + lifecycle: 'experimental', + }, + }; + } + + async getTechDocsMetadata(_entityId: EntityName) { + return { + site_name: 'Live preview environment', + site_description: '', + }; + } +} + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevStorageApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: techdocsApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +]; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx new file mode 100644 index 0000000000..c7b1c846c4 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx new file mode 100644 index 0000000000..073cf6edad --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/embedded-techdocs-app/src/components/Root/Root.tsx b/packages/embedded-techdocs-app/src/components/Root/Root.tsx new file mode 100644 index 0000000000..65e75a488a --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/Root.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; +import { + Sidebar, + SidebarPage, + sidebarConfig, + SidebarContext, + SidebarItem, + SidebarDivider, +} from '@backstage/core-components'; +import { NavLink } from 'react-router-dom'; + +const useSidebarLogoStyles = makeStyles({ + root: { + width: sidebarConfig.drawerWidthClosed, + height: 3 * sidebarConfig.logoHeight, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: -14, + }, + link: { + width: sidebarConfig.drawerWidthClosed, + marginLeft: 24, + }, +}); + +const SidebarLogo = () => { + const classes = useSidebarLogoStyles(); + const { isOpen } = useContext(SidebarContext); + + return ( +
+ + {isOpen ? : } + +
+ ); +}; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + {/* Global nav, not org-specific */} + + {/* End global nav */} + + {children} + +); diff --git a/packages/embedded-techdocs-app/src/components/Root/index.ts b/packages/embedded-techdocs-app/src/components/Root/index.ts new file mode 100644 index 0000000000..dff706f08f --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Root } from './Root'; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx new file mode 100644 index 0000000000..37e5f233d7 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Content } from '@backstage/core-components'; + +import { + Reader, + TechDocsPage, + TechDocsPageHeader, +} from '@backstage/plugin-techdocs'; + +const DefaultTechDocsPage = () => { + const techDocsMetadata = { + site_name: 'Live preview environment', + site_description: '', + }; + + return ( + + {({ entityRef, onReady }) => ( + <> + + + + + + )} + + ); +}; + +export const techDocsPage = ; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts new file mode 100644 index 0000000000..421e244613 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './TechDocsPage'; diff --git a/packages/embedded-techdocs-app/src/index.tsx b/packages/embedded-techdocs-app/src/index.tsx new file mode 100644 index 0000000000..b15bc4c102 --- /dev/null +++ b/packages/embedded-techdocs-app/src/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/embedded-techdocs-app/src/plugins.ts b/packages/embedded-techdocs-app/src/plugins.ts new file mode 100644 index 0000000000..42fc16b339 --- /dev/null +++ b/packages/embedded-techdocs-app/src/plugins.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; diff --git a/packages/embedded-techdocs-app/src/setupTests.ts b/packages/embedded-techdocs-app/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/embedded-techdocs-app/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/packages/techdocs-cli/.eslintrc.js b/packages/techdocs-cli/.eslintrc.js new file mode 100644 index 0000000000..884f559c0f --- /dev/null +++ b/packages/techdocs-cli/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + 'no-restricted-imports': 0, + }, + }, + ], +}; diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md new file mode 100644 index 0000000000..a8c6cd04fe --- /dev/null +++ b/packages/techdocs-cli/CHANGELOG.md @@ -0,0 +1,70 @@ +# @techdocs/cli + +## 0.8.2 + +### Patch Changes + +- 8fc7384: Allow to execute techdocs-cli serve using docker techdocs-container on Windows + +## 0.8.1 + +### Patch Changes + +- 0187424: Separate build and publish release steps + +## 0.8.0 + +### Minor Changes + +- c6f437a: OpenStack Swift configuration changed due to OSS SDK Client change in @backstage/techdocs-common, it was a breaking change. + PR Reference: https://github.com/backstage/backstage/pull/6839 + +### Patch Changes + +- 05f0409: Merge Jobs for Release Pull Requests and Package Publishes + +## 0.7.0 + +### Minor Changes + +- 9d1f8d8: The `techdocs-cli publish` command will now publish TechDocs content to remote + storage using the lowercase'd entity triplet as the storage path. This is in + line with the beta release of the TechDocs plugin (`v0.11.0`). + + If you have been running `techdocs-cli` prior to this version, you will need to + follow this [migration guide](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). + +## 0.6.2 + +### Patch Changes + +- f1bcf1a: Changelog (from v0.6.1 to v0.6.2) + + #### :bug: Bug Fix + + - `techdocs-cli` + - [#105](https://github.com/backstage/techdocs-cli/pull/105) Add azureAccountKey parameter back to the publish command ([@emmaindal](https://github.com/emmaindal)) + + #### :house: Internal + + - `embedded-techdocs-app` + - [#122](https://github.com/backstage/techdocs-cli/pull/122) chore(deps-dev): bump @types/node from 12.20.20 to 16.7.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#120](https://github.com/backstage/techdocs-cli/pull/120) chore(deps-dev): bump @types/react-dom from 16.9.14 to 17.0.9 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#119](https://github.com/backstage/techdocs-cli/pull/119) chore(deps-dev): bump @testing-library/user-event from 12.8.3 to 13.2.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#118](https://github.com/backstage/techdocs-cli/pull/118) chore(deps-dev): bump @testing-library/react from 10.4.9 to 12.0.0 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - Other + - [#117](https://github.com/backstage/techdocs-cli/pull/117) chore(deps): bump @backstage/plugin-catalog from 0.6.11 to 0.6.12 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#124](https://github.com/backstage/techdocs-cli/pull/124) Update release process docs ([@emmaindal](https://github.com/emmaindal)) + - [#116](https://github.com/backstage/techdocs-cli/pull/116) ignore dependabot branches for project board workflow ([@emmaindal](https://github.com/emmaindal)) + - [#106](https://github.com/backstage/techdocs-cli/pull/106) Configure dependabot for all packages ([@emmaindal](https://github.com/emmaindal)) + - [#102](https://github.com/backstage/techdocs-cli/pull/102) readme: add information about running techdocs-common locally ([@vcapretz](https://github.com/vcapretz)) + - [#103](https://github.com/backstage/techdocs-cli/pull/103) Introduce changesets and improve the publish workflow ([@minkimcello](https://github.com/minkimcello)) + - [#101](https://github.com/backstage/techdocs-cli/pull/101) update yarn lockfile to get rid of old version of node-forge ([@emmaindal](https://github.com/emmaindal)) + + #### Committers: 3 + + Thank you for contributing ❤️ + + - `Emma Indal` ([@emmaindal](https://github.com/emmaindal)) + - `Min Kim` ([@minkimcello](https://github.com/minkimcello)) + - `Vitor Capretz` ([@vcapretz](https://github.com/vcapretz)) diff --git a/packages/techdocs-cli/README.md b/packages/techdocs-cli/README.md new file mode 100644 index 0000000000..6f070147eb --- /dev/null +++ b/packages/techdocs-cli/README.md @@ -0,0 +1,68 @@ +# techdocs-cli + +[![NPM Version badge](https://img.shields.io/npm/v/@techdocs/cli)](https://www.npmjs.com/package/@techdocs/cli) + +## Usage + +See [techdocs-cli usage docs](https://backstage.io/docs/features/techdocs/cli). + +## Development + +NOTE: When we build `techdocs-cli` it copies the output `embedded-techdocs-app` +bundle into the `packages/techdocs-cli/dist` which is then published with the +`@techdocs/cli` npm package. + +### Running + +```sh +# From the root of this repository run +# NOTE: This will build the embedded-techdocs-app and copy the output into the cli dist directory +yarn build --scope @techdocs/cli + +# Now execute the binary +packages/techdocs-cli/bin/techdocs-cli + +# ... or as a shell alias in ~/.zshrc or ~/.zprofile or ~/.bashrc or similar +export PATH=/path/to/backstage/packages/techdocs-cli/bin:$PATH +``` + +If you want to test live test changes to the `packages/embedded-techdocs-app` +you can serve the app and run the CLI using the following commands: + +```sh +# Open a shell to the embedded-techdocs-app directory +cd packages/embedded-techdocs-app + +# Run the embedded-techdocs-app using dev mode +yarn start + +# In another shell use the techdocs-cli from the root of this repo +yarn techdocs-cli:dev [...options] +``` + +### Testing + +Running unit tests requires mkdocs to be installed locally: + +```sh +pip install mkdocs +pip install mkdocs-techdocs-core +``` + +Then run `yarn test`. + +### Use an example docs project + +We have created an [example documentation project](https://github.com/backstage/techdocs-container/tree/main/mock-docs) and it's shipped with [techdocs-container](https://github.com/backstage/techdocs-container) repository, for the purpose of local development. But you are free to create your own local test site. All it takes is a `docs/index.md` and `mkdocs.yml` in a directory. + +```sh +git clone https://github.com/backstage/techdocs-container.git + +cd techdocs-container/mock-docs + +# To get a view of your docs in Backstage, use: +techdocs-cli serve + +# To view the raw mkdocs site (without Backstage), use: +techdocs-cli serve:mkdocs +``` diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli new file mode 100755 index 0000000000..6c0bbd37b5 --- /dev/null +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal) { + require('..'); +} else { + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json new file mode 100644 index 0000000000..a7ac3c3d80 --- /dev/null +++ b/packages/techdocs-cli/package.json @@ -0,0 +1,70 @@ +{ + "name": "@techdocs/cli", + "description": "Utility CLI for managing TechDocs sites in Backstage.", + "version": "0.8.4", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-cli" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "types": "", + "scripts": { + "start": "nodemon --", + "build": "./scripts/build.sh", + "clean": "backstage-cli clean", + "lint": "backstage-cli lint", + "test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts", + "test:e2e": "backstage-cli test --testPathPattern src/e2e.test.ts --runInBand" + }, + "bin": { + "techdocs-cli": "bin/techdocs-cli" + }, + "devDependencies": { + "@backstage/cli": "^0.8.0", + "@types/commander": "^2.12.2", + "@types/fs-extra": "^9.0.6", + "@types/http-proxy": "^1.17.4", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dev-utils": "^9.0.4", + "@types/serve-handler": "^6.1.0", + "@types/webpack-env": "^1.15.3", + "embedded-techdocs-app": "0.0.0", + "nodemon": "^2.0.2", + "ts-node": "^10.0.0" + }, + "files": [ + "bin", + "dist" + ], + "nodemonConfig": { + "watch": "./src", + "exec": "bin/techdocs-cli", + "ext": "ts" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.7", + "@backstage/catalog-model": "^0.9.5", + "@backstage/config": "^0.1.10", + "@backstage/techdocs-common": "^0.10.4", + "@types/dockerode": "^3.2.1", + "commander": "^6.1.0", + "dockerode": "^3.2.1", + "fs-extra": "^9.0.1", + "http-proxy": "^1.18.1", + "react-dev-utils": "^11.0.4", + "serve-handler": "^6.1.3", + "winston": "^3.2.1" + } +} diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh new file mode 100755 index 0000000000..a56be00206 --- /dev/null +++ b/packages/techdocs-cli/scripts/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Copyright 2020 The Backstage Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# Build the TechDocs CLI +npx backstage-cli -- build --outputs cjs + +# Make sure to do `yarn run build` in packages/embedded-techdocs before building here. + +EMBEDDED_TECHDOCS_APP_PATH=../embedded-techdocs-app +TECHDOCS_PREVIEW_SOURCE=$EMBEDDED_TECHDOCS_APP_PATH/dist +TECHDOCS_PREVIEW_DEST=dist/techdocs-preview-bundle + +# Build the embedded-techdocs-app +pushd $EMBEDDED_TECHDOCS_APP_PATH >/dev/null +yarn build +popd >/dev/null + +cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST + +# Write to console +echo "[techdocs-cli]: Built the dist/ folder" +echo "[techdocs-cli]: Imported @backstage/plugin-techdocs dist/ folder into techdocs-preview-bundle/" diff --git a/packages/techdocs-cli/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts new file mode 100644 index 0000000000..a255805139 --- /dev/null +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import Docker from 'dockerode'; +import { + TechdocsGenerator, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import { DockerContainerRunner } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { + convertTechDocsRefToLocationAnnotation, + createLogger, +} from '../../lib/utility'; +import { stdout } from 'process'; + +export default async function generate(cmd: Command) { + // Use techdocs-common package to generate docs. Keep consistency between Backstage and CI generating docs. + // Docs can be prepared using actions/checkout or git clone, or similar paradigms on CI. The TechDocs CI workflow + // will run on the CI pipeline containing the documentation files. + + const logger = createLogger({ verbose: cmd.verbose }); + + const sourceDir = resolve(cmd.sourceDir); + const outputDir = resolve(cmd.outputDir); + const dockerImage = cmd.dockerImage; + const pullImage = cmd.pull; + + logger.info(`Using source dir ${sourceDir}`); + logger.info(`Will output generated files in ${outputDir}`); + + logger.verbose('Creating output directory if it does not exist.'); + + await fs.ensureDir(outputDir); + + const config = new ConfigReader({ + techdocs: { + generator: { + runIn: cmd.docker ? 'docker' : 'local', + dockerImage, + pullImage, + }, + }, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + let parsedLocationAnnotation = {} as ParsedLocationAnnotation; + if (cmd.techdocsRef) { + try { + parsedLocationAnnotation = convertTechDocsRefToLocationAnnotation( + cmd.techdocsRef, + ); + } catch (err) { + logger.error(err.message); + } + } + + // Generate docs using @backstage/techdocs-common + const techdocsGenerator = await TechdocsGenerator.fromConfig(config, { + logger, + containerRunner, + }); + + logger.info('Generating documentation...'); + + await techdocsGenerator.run({ + inputDir: sourceDir, + outputDir, + ...(cmd.techdocsRef + ? { + parsedLocationAnnotation, + } + : {}), + logger, + etag: cmd.etag, + ...(process.env.LOG_LEVEL === 'debug' ? { logStream: stdout } : {}), + }); + + logger.info('Done!'); +} diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts new file mode 100644 index 0000000000..776b6dad00 --- /dev/null +++ b/packages/techdocs-cli/src/commands/index.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommanderStatic } from 'commander'; +import { TechdocsGenerator } from '@backstage/techdocs-common'; + +const defaultDockerImage = TechdocsGenerator.defaultDockerImage; + +export function registerCommands(program: CommanderStatic) { + program + .command('generate') + .description('Generate TechDocs documentation site using MkDocs.') + .option( + '--source-dir ', + 'Source directory containing mkdocs.yml and docs/ directory.', + '.', + ) + .option( + '--output-dir ', + 'Output directory containing generated TechDocs site.', + './site/', + ) + .option( + '--docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option('--no-pull', 'Do not pull the latest docker image', false) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable and plugins in current user environment.', + ) + .option( + '--techdocs-ref ', + 'The repository hosting documentation source files e.g. github:https://ghe.mycompany.net.com/org/repo.' + + '\nThis value is same as the backstage.io/techdocs-ref annotation of the corresponding Backstage entity.' + + '\nIt is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml if not found.\n', + ) + .option( + '--etag ', + 'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .alias('build') + .action(lazy(() => import('./generate/generate').then(m => m.default))); + + program + .command('migrate') + .description( + 'Migrate objects with case-sensitive entity triplets to lower-case versions.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--removeOriginal', + 'Optional Files are copied by default. If flag is set, files are renamed/moved instead.', + false, + ) + .option( + '--concurrency ', + 'Optional Controls the number of API requests allowed to be performed simultaneously.', + '25', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./migrate/migrate').then(m => m.default))); + + program + .command('publish') + .description( + 'Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .requiredOption( + '--entity ', + '(Required always) Entity uid separated by / in namespace/kind/name order (case-sensitive). Example: default/Component/myEntity ', + ) + .option( + '--legacyUseCaseSensitiveTripletPaths', + 'Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). Only use if your TechDocs backend is configured the same way.', + false, + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--directory ', + 'Path of the directory containing generated files to publish', + './site/', + ) + .action(lazy(() => import('./publish/publish').then(m => m.default))); + + program + .command('serve:mkdocs') + .description('Serve a documentation project locally using MkDocs serve.') + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, run `mkdocs serve` in current user environment.', + ) + .option('-p, --port ', 'Port to serve documentation locally', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/mkdocs').then(m => m.default))); + + program + .command('serve') + .description( + 'Serve a documentation project locally in a Backstage app-like environment', + ) + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable in current user environment.', + ) + .option('--mkdocs-port ', 'Port for MkDocs server to use', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/serve').then(m => m.default))); +} + +// Wraps an action function so that it always exits and handles errors +// Humbly taken from backstage-cli's registerCommands +function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + process.exit(0); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error.message); + process.exit(1); + } + }; +} diff --git a/packages/techdocs-cli/src/commands/migrate/migrate.ts b/packages/techdocs-cli/src/commands/migrate/migrate.ts new file mode 100644 index 0000000000..1cd8fca65b --- /dev/null +++ b/packages/techdocs-cli/src/commands/migrate/migrate.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function migrate(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + if (!publisher.migrateDocsCase) { + throw new Error(`Migration not implemented for ${cmd.publisherType}`); + } + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + throw new Error(''); + } + + // Validate and parse migration arguments. + const removeOriginal = cmd.removeOriginal; + const numericConcurrency = parseInt(cmd.concurrency, 10); + + if (!Number.isInteger(numericConcurrency) || numericConcurrency <= 0) { + throw new Error( + `Concurrency must be a number greater than 1. ${cmd.concurrency} provided.`, + ); + } + + await publisher.migrateDocsCase({ + concurrency: numericConcurrency, + removeOriginal, + }); +} diff --git a/packages/techdocs-cli/src/commands/publish/publish.ts b/packages/techdocs-cli/src/commands/publish/publish.ts new file mode 100644 index 0000000000..11a47d4a71 --- /dev/null +++ b/packages/techdocs-cli/src/commands/publish/publish.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Entity } from '@backstage/catalog-model'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function publish(cmd: Command): Promise { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + return Promise.reject(new Error('')); + } + + const [namespace, kind, name] = cmd.entity.split('/'); + const entity = { + kind, + metadata: { + namespace, + name, + }, + } as Entity; + + const directory = resolve(cmd.directory); + await publisher.publish({ entity, directory }); + + return true; +} diff --git a/packages/techdocs-cli/src/commands/serve/mkdocs.ts b/packages/techdocs-cli/src/commands/serve/mkdocs.ts new file mode 100644 index 0000000000..f6779c5146 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/mkdocs.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { createLogger } from '../../lib/utility'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; + +export default async function serveMkdocs(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const dockerAddr = `http://0.0.0.0:${cmd.port}`; + const localAddr = `http://127.0.0.1:${cmd.port}`; + const expectedDevAddr = cmd.docker ? dockerAddr : localAddr; + // We want to open browser only once based on a log. + let boolOpenBrowserTriggered = false; + + const logFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line in between + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + // Logs from container is verbose. + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !boolOpenBrowserTriggered && + line.includes(`Serving on ${expectedDevAddr}`) + ) { + // Always open the local address, since 0.0.0.0 belongs to docker + logger.info(`\nStarting mkdocs server on ${localAddr}\n`); + openBrowser(localAddr); + boolOpenBrowserTriggered = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + + // Commander stores --no-docker in cmd.docker variable + const childProcess = await runMkdocsServer({ + port: cmd.port, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: logFunc, + stderrLogFunc: logFunc, + }); + + // Keep waiting for user to cancel the process + await waitForSignal([childProcess]); +} diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts new file mode 100644 index 0000000000..62682ae1eb --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import path from 'path'; +import openBrowser from 'react-dev-utils/openBrowser'; +import HTTPServer from '../../lib/httpServer'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; +import { createLogger } from '../../lib/utility'; + +export default async function serve(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + // Determine if we want to run in local dev mode or not + // This will run the backstage http server on a different port and only used + // for proxying mkdocs to the backstage app running locally (e.g. with webpack-dev-server) + const isDevMode = Object.keys(process.env).includes('TECHDOCS_CLI_DEV_MODE') + ? true + : false; + + // TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle + // a backstage app, we define app.baseUrl in the app-config.yaml. + // Hence, it is complicated to make this configurable. + const backstagePort = 3000; + const backstageBackendPort = 7000; + + const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`; + const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`; + const mkdocsExpectedDevAddr = cmd.docker ? mkdocsDockerAddr : mkdocsLocalAddr; + + let mkdocsServerHasStarted = false; + const mkdocsLogFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !mkdocsServerHasStarted && + line.includes(`Serving on ${mkdocsExpectedDevAddr}`) + ) { + mkdocsServerHasStarted = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + logger.info('Starting mkdocs server.'); + const mkdocsChildProcess = await runMkdocsServer({ + port: cmd.mkdocsPort, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: mkdocsLogFunc, + stderrLogFunc: mkdocsLogFunc, + }); + + // Wait until mkdocs server has started so that Backstage starts with docs loaded + // Takes 1-5 seconds + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise(r => setTimeout(r, 1000)); + if (mkdocsServerHasStarted) { + break; + } + logger.info('Waiting for mkdocs server to start...'); + } + + if (!mkdocsServerHasStarted) { + logger.error( + 'mkdocs server did not start. Exiting. Try re-running command with -v option for more details.', + ); + } + + // Run the embedded-techdocs Backstage app + const techdocsPreviewBundlePath = path.join( + path.dirname(require.resolve('@techdocs/cli/package.json')), + 'dist', + 'techdocs-preview-bundle', + ); + + const httpServer = new HTTPServer( + techdocsPreviewBundlePath, + isDevMode ? backstageBackendPort : backstagePort, + cmd.mkdocsPort, + cmd.verbose, + ); + + httpServer + .serve() + .catch(err => { + logger.error(err); + mkdocsChildProcess.kill(); + process.exit(1); + }) + .then(() => { + // The last three things default/component/local/ don't matter. They can be anything. + openBrowser( + `http://localhost:${backstagePort}/docs/default/component/local/`, + ); + logger.info( + `Serving docs in Backstage at http://localhost:${backstagePort}/docs/default/component/local/\nOpening browser.`, + ); + }); + + await waitForSignal([mkdocsChildProcess]); +} diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts new file mode 100644 index 0000000000..42afb5a1fa --- /dev/null +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawn } from 'child_process'; +import path from 'path'; + +const PROJECT_ROOT_DIR = path.resolve(__dirname, '..'); +const FIXTURE_DIR = path.resolve(PROJECT_ROOT_DIR, 'src/fixture'); + +describe('end-to-end', () => { + it('shows help text', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand(['--help']); + + expect(proc.combinedStdOutErr).toContain('Usage: techdocs-cli [options]'); + expect(proc.exit).toEqual(0); + }); + + it('can generate', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand(['generate', '--no-docker'], { + cwd: FIXTURE_DIR, + killAfter: 8000, + }); + + expect(proc.combinedStdOutErr).toContain('Successfully generated docs'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in mkdocs', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand( + ['serve:mkdocs', '--no-docker'], + { + cwd: FIXTURE_DIR, + killAfter: 8000, + }, + ); + + expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in backstage', async () => { + jest.setTimeout(10000); + const proc = await executeTechDocsCliCommand( + ['serve', '--no-docker', '--mkdocs-port=8888'], + { + cwd: FIXTURE_DIR, + killAfter: 8000, + }, + ); + + expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); + expect(proc.combinedStdOutErr).toContain('Serving docs in Backstage at'); + expect(proc.exit).toEqual(0); + }); +}); + +type CommandResponse = { + stdout: string; + stderr: string; + combinedStdOutErr: string; + exit: number; +}; + +type ExecuteCommandOptions = { + killAfter?: number; + cwd?: string; +}; + +function executeTechDocsCliCommand( + args: string[], + opts: ExecuteCommandOptions = {}, +): Promise { + return new Promise(resolve => { + const pathToCli = path.resolve(PROJECT_ROOT_DIR, 'bin/techdocs-cli'); + const commandResponse = { + stdout: '', + stderr: '', + combinedStdOutErr: '', + exit: 0, + }; + + const listen = spawn(pathToCli, args, { + cwd: opts.cwd, + }); + + const stdOutChunks: any[] = []; + const stdErrChunks: any[] = []; + const combinedChunks: any[] = []; + + listen.stdout.on('data', data => { + stdOutChunks.push(data); + combinedChunks.push(data); + }); + + listen.stderr.on('data', data => { + stdErrChunks.push(data); + combinedChunks.push(data); + }); + + listen.on('exit', code => { + commandResponse.exit = code as number; + commandResponse.stdout = Buffer.concat(stdOutChunks).toString('utf8'); + commandResponse.stderr = Buffer.concat(stdErrChunks).toString('utf8'); + commandResponse.combinedStdOutErr = + Buffer.concat(combinedChunks).toString('utf8'); + resolve(commandResponse); + }); + + if (opts.killAfter) { + setTimeout(() => { + listen.kill('SIGTERM'); + }, opts.killAfter); + } + }); +} diff --git a/packages/techdocs-cli/src/fixture/docs/README.md b/packages/techdocs-cli/src/fixture/docs/README.md new file mode 100644 index 0000000000..c32f73f6f4 --- /dev/null +++ b/packages/techdocs-cli/src/fixture/docs/README.md @@ -0,0 +1 @@ +# Test Fixture diff --git a/packages/techdocs-cli/src/fixture/mkdocs.yml b/packages/techdocs-cli/src/fixture/mkdocs.yml new file mode 100644 index 0000000000..5d5c2a02ae --- /dev/null +++ b/packages/techdocs-cli/src/fixture/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: docs-test-fixture +site_description: Documentation site test fixture + +nav: + - HOME: README.md + +plugins: + - techdocs-core diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts new file mode 100644 index 0000000000..6f4437cbce --- /dev/null +++ b/packages/techdocs-cli/src/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import program from 'commander'; +import { registerCommands } from './commands'; +import { version } from '../package.json'; + +const main = (argv: string[]) => { + program.name('techdocs-cli').version(version); + + registerCommands(program); + + program.parse(argv); +}; + +main(process.argv); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts new file mode 100644 index 0000000000..0a4899f70d --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import { PublisherConfig } from './PublisherConfig'; + +describe('getValidPublisherConfig', () => { + it('should not allow unknown publisher types', () => { + const invalidConfig = { + publisherType: 'unknown publisher', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(invalidConfig)).toThrowError( + `Unknown publisher type ${invalidConfig.publisherType}`, + ); + }); + + describe('for azureBlobStorage', () => { + it('should require --azureAccountName', () => { + const config = { + publisherType: 'azureBlobStorage', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + 'azureBlobStorage requires --azureAccountName to be specified', + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'azureBlobStorage', + azureAccountName: 'someAccountName', + storageName: 'someContainer', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'azureBlobStorage', + ); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ), + ).toBe('someContainer'); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ), + ).toBe('someAccountName'); + }); + }); + + describe('for awsS3', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketName'), + ).toBe('someStorageName'); + }); + }); + + describe('for openStackSwift', () => { + it('should throw error on missing parameters', () => { + const config = { + publisherType: 'openStackSwift', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + `openStackSwift requires the following params to be specified: ${[ + 'osAuthUrl', + 'osSwiftUrl', + ].join(', ')}`, + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'openStackSwift', + storageName: 'someStorageName', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + osAuthUrl: 'someAuthUrl', + osSwiftUrl: 'someSwiftUrl', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'openStackSwift', + ); + expect( + actualConfig.getConfig('techdocs.publisher.openStackSwift').get(), + ).toMatchObject({ + containerName: 'someStorageName', + credentials: { + id: 'someCredentialId', + secret: 'someSecret', + }, + authUrl: 'someAuthUrl', + swiftUrl: 'someSwiftUrl', + }); + }); + }); + + describe('for googleGcs', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'googleGcs', + storageName: 'someStorageName', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'googleGcs', + ); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketName'), + ).toBe('someStorageName'); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.ts b/packages/techdocs-cli/src/lib/PublisherConfig.ts new file mode 100644 index 0000000000..6243348156 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { Command } from 'commander'; + +type Publisher = keyof typeof PublisherConfig['configFactories']; +type PublisherConfiguration = { + [p in Publisher]?: any; +} & { + type: Publisher; +}; + +/** + * Helper when working with publisher-related configurations. + */ +export class PublisherConfig { + /** + * Maps publisher-specific config keys to config getters. + */ + private static configFactories = { + awsS3: PublisherConfig.getValidAwsS3Config, + azureBlobStorage: PublisherConfig.getValidAzureConfig, + googleGcs: PublisherConfig.getValidGoogleGcsConfig, + openStackSwift: PublisherConfig.getValidOpenStackSwiftConfig, + }; + + /** + * Returns Backstage config suitable for use when instantiating a Publisher. If + * there are any missing or invalid options provided, an error is thrown. + * + * Note: This assumes that proper credentials are set in Environment + * variables for the respective GCS/AWS clients to work. + */ + static getValidConfig(cmd: Command): ConfigReader { + const publisherType = cmd.publisherType; + + if (!PublisherConfig.isKnownPublisher(publisherType)) { + throw new Error(`Unknown publisher type ${cmd.publisherType}`); + } + + return new ConfigReader({ + // This backend config is not used at all. Just something needed a create a mock discovery instance. + backend: { + baseUrl: 'http://localhost:7000', + listen: { + port: 7000, + }, + }, + techdocs: { + publisher: PublisherConfig.configFactories[publisherType](cmd), + legacyUseCaseSensitiveTripletPaths: + cmd.legacyUseCaseSensitiveTripletPaths, + }, + }); + } + + /** + * Typeguard to ensure the publisher has a known config getter. + */ + private static isKnownPublisher( + type: string, + ): type is keyof typeof PublisherConfig['configFactories'] { + return PublisherConfig.configFactories.hasOwnProperty(type); + } + + /** + * Retrieve valid AWS S3 configuration based on the command. + */ + private static getValidAwsS3Config(cmd: Command): PublisherConfiguration { + return { + type: 'awsS3', + awsS3: { + bucketName: cmd.storageName, + ...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }), + ...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }), + ...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }), + }, + }; + } + + /** + * Retrieve valid Azure Blob Storage configuration based on the command. + */ + private static getValidAzureConfig(cmd: Command): PublisherConfiguration { + if (!cmd.azureAccountName) { + throw new Error( + `azureBlobStorage requires --azureAccountName to be specified`, + ); + } + + return { + type: 'azureBlobStorage', + azureBlobStorage: { + containerName: cmd.storageName, + credentials: { + accountName: cmd.azureAccountName, + accountKey: cmd.azureAccountKey, + }, + }, + }; + } + + /** + * Retrieve valid GCS configuration based on the command. + */ + private static getValidGoogleGcsConfig(cmd: Command): PublisherConfiguration { + return { + type: 'googleGcs', + googleGcs: { + bucketName: cmd.storageName, + }, + }; + } + + /** + * Retrieves valid OpenStack Swift configuration based on the command. + */ + private static getValidOpenStackSwiftConfig( + cmd: Command, + ): PublisherConfiguration { + const missingParams = [ + 'osCredentialId', + 'osSecret', + 'osAuthUrl', + 'osSwiftUrl', + ].filter((param: string) => !cmd[param]); + + if (missingParams.length) { + throw new Error( + `openStackSwift requires the following params to be specified: ${missingParams.join( + ', ', + )}`, + ); + } + + return { + type: 'openStackSwift', + openStackSwift: { + containerName: cmd.storageName, + credentials: { + id: cmd.osCredentialId, + secret: cmd.osSecret, + }, + authUrl: cmd.osAuthUrl, + swiftUrl: cmd.osSwiftUrl, + }, + }; + } +} diff --git a/packages/techdocs-cli/src/lib/httpServer.ts b/packages/techdocs-cli/src/lib/httpServer.ts new file mode 100644 index 0000000000..0402ce4e69 --- /dev/null +++ b/packages/techdocs-cli/src/lib/httpServer.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import serveHandler from 'serve-handler'; +import http from 'http'; +import httpProxy from 'http-proxy'; +import { createLogger } from './utility'; + +export default class HTTPServer { + private readonly proxyEndpoint: string; + private readonly backstageBundleDir: string; + private readonly backstagePort: number; + private readonly mkdocsPort: number; + private readonly verbose: boolean; + + constructor( + backstageBundleDir: string, + backstagePort: number, + mkdocsPort: number, + verbose: boolean, + ) { + this.proxyEndpoint = '/api/'; + this.backstageBundleDir = backstageBundleDir; + this.backstagePort = backstagePort; + this.mkdocsPort = mkdocsPort; + this.verbose = verbose; + } + + // Create a Proxy for mkdocs server + private createProxy() { + const proxy = httpProxy.createProxyServer({ + target: `http://localhost:${this.mkdocsPort}`, + }); + + return (request: http.IncomingMessage): [httpProxy, string] => { + // If the request goes to /api/ we want to remove /api/ from the prefix of the request URL. + // e.g. ['/', 'api', pathChunks] + const [, , ...pathChunks] = request.url?.split('/') ?? []; + const forwardPath = pathChunks.join('/'); + + return [proxy, forwardPath]; + }; + } + + public async serve(): Promise { + return new Promise((resolve, reject) => { + const proxyHandler = this.createProxy(); + const server = http.createServer( + (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.url?.startsWith(this.proxyEndpoint)) { + const [proxy, forwardPath] = proxyHandler(request); + + proxy.on('error', (error: Error) => { + reject(error); + }); + + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + + request.url = forwardPath; + return proxy.web(request, response); + } + + return serveHandler(request, response, { + public: this.backstageBundleDir, + trailingSlash: true, + rewrites: [{ source: '**', destination: 'index.html' }], + }); + }, + ); + + const logger = createLogger({ verbose: false }); + server.listen(this.backstagePort, () => { + if (this.verbose) { + logger.info( + `[techdocs-preview-bundle] Running local version of Backstage at http://localhost:${this.backstagePort}`, + ); + } + resolve(server); + }); + + server.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts new file mode 100644 index 0000000000..e06541a3e8 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { runMkdocsServer } from './mkdocsServer'; +import { run } from './run'; + +jest.mock('./run', () => { + return { + run: jest.fn(), + }; +}); + +describe('runMkdocsServer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('docker', () => { + it('should run docker directly by default', async () => { + await runMkdocsServer({}); + + const quotedCwd = `"${process.cwd()}":/content`; + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + quotedCwd, + '8000:8000', + 'serve', + '--dev-addr', + '0.0.0.0:8000', + 'spotify/techdocs', + ]), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['5678:5678', '0.0.0.0:5678']), + expect.objectContaining({}), + ); + }); + + it('should accept custom docker image', async () => { + await runMkdocsServer({ dockerImage: 'my-org/techdocs' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['my-org/techdocs']), + expect.objectContaining({}), + ); + }); + }); + + describe('mkdocs', () => { + it('should run mkdocs if specified', async () => { + await runMkdocsServer({ useDocker: false }); + + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['serve', '--dev-addr', '127.0.0.1:8000']), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ useDocker: false, port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['127.0.0.1:5678']), + expect.objectContaining({}), + ); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts new file mode 100644 index 0000000000..d35edc41b5 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChildProcess } from 'child_process'; +import { run, LogFunc } from './run'; + +export const runMkdocsServer = async (options: { + port?: string; + useDocker?: boolean; + dockerImage?: string; + stdoutLogFunc?: LogFunc; + stderrLogFunc?: LogFunc; +}): Promise => { + const port = options.port ?? '8000'; + const useDocker = options.useDocker ?? true; + const dockerImage = options.dockerImage ?? 'spotify/techdocs'; + + if (useDocker) { + return await run( + 'docker', + [ + 'run', + '--rm', + '-w', + '/content', + '-v', + `"${process.cwd()}":/content`, + '-p', + `${port}:${port}`, + dockerImage, + 'serve', + '--dev-addr', + `0.0.0.0:${port}`, + ], + { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }, + ); + } + + return await run('mkdocs', ['serve', '--dev-addr', `127.0.0.1:${port}`], { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }); +}; diff --git a/packages/techdocs-cli/src/lib/run.ts b/packages/techdocs-cli/src/lib/run.ts new file mode 100644 index 0000000000..cbc6227589 --- /dev/null +++ b/packages/techdocs-cli/src/lib/run.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { spawn, SpawnOptions, ChildProcess } from 'child_process'; + +export type LogFunc = (data: Buffer | string) => void; +type SpawnOptionsPartialEnv = Omit & { + env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; +}; + +// TODO: Accept log functions to pipe logs with. +// Runs a child command, returning the child process instance. +// Use it along with waitForSignal to run a long running process e.g. mkdocs serve +export const run = async ( + name: string, + args: string[] = [], + options: SpawnOptionsPartialEnv = {}, +): Promise => { + const { stdoutLogFunc, stderrLogFunc } = options; + + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: 'true', + ...(options.env ?? {}), + }; + + // Refer: https://nodejs.org/api/child_process.html#child_process_subprocess_stdio + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + + const childProcess = spawn(name, args, { + stdio: stdio, + shell: true, + ...options, + env, + }); + + if (stdoutLogFunc && childProcess.stdout) { + childProcess.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && childProcess.stderr) { + childProcess.stderr.on('data', stderrLogFunc); + } + + return childProcess; +}; + +// Block indefinitely and wait for a signal to kill the child process(es) +// Throw error if any child process errors +// Resolves only when all processes exit with status code 0 +export async function waitForSignal( + childProcesses: Array, +): Promise { + const promises: Array> = []; + + childProcesses.forEach(childProcess => { + if (typeof childProcess.exitCode === 'number') { + if (childProcess.exitCode) { + throw new Error(`Non zero exit code from child process`); + } + return; + } + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + childProcess.kill(signal); + // exit instead of resolve. The process is shutting down and resolving a promise here logs an error + process.exit(); + }); + } + + promises.push( + new Promise((resolve, reject) => { + childProcess.once('error', error => reject(error)); + childProcess.once('exit', code => { + if (code) { + reject(new Error(`Non zero exit code from child process`)); + } else { + resolve(); + } + }); + }), + ); + }); + + await Promise.all(promises); +} diff --git a/packages/techdocs-cli/src/lib/utility.ts b/packages/techdocs-cli/src/lib/utility.ts new file mode 100644 index 0000000000..87dbbfd08d --- /dev/null +++ b/packages/techdocs-cli/src/lib/utility.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + RemoteProtocol, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import * as winston from 'winston'; + +export const convertTechDocsRefToLocationAnnotation = ( + techdocsRef: string, +): ParsedLocationAnnotation => { + // Split on the first colon for the protocol and the rest after the first split + // is the location. + const [type, target] = techdocsRef.split(/:(.+)/) as [ + RemoteProtocol?, + string?, + ]; + + if (!type || !target) { + throw new Error( + `Can not parse --techdocs-ref ${techdocsRef}. Should be of type HOST:URL.`, + ); + } + + return { type, target }; +}; + +export const createLogger = ({ + verbose = false, +}: { + verbose: boolean; +}): winston.Logger => { + const logger = winston.createLogger({ + level: verbose ? 'verbose' : 'info', + transports: [ + new winston.transports.Console({ format: winston.format.simple() }), + ], + }); + + return logger; +}; diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index c8f35508fa..5b77059f1d 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -99,6 +99,7 @@ const SKIPPED_PACKAGES = [ join('packages', 'codemods'), join('packages', 'create-app'), join('packages', 'e2e-test'), + join('packages', 'embedded-techdocs-app'), join('packages', 'storybook'), join('packages', 'techdocs-cli'), ]; diff --git a/yarn.lock b/yarn.lock index 4c42941ad9..f51a95fccd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6825,6 +6825,14 @@ dependencies: classnames "*" +"@types/clean-css@*": + version "4.2.5" + resolved "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89" + integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw== + dependencies: + "@types/node" "*" + source-map "^0.6.0" + "@types/codemirror@^0.0.108": version "0.0.108" resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz#e640422b666bf49251b384c390cdeb2362585bde" @@ -6856,6 +6864,13 @@ resolved "https://registry.npmjs.org/@types/command-exists/-/command-exists-1.2.0.tgz#d97e0ed10097090e4ab0367ed425b0312fad86f3" integrity sha512-ugsxEJfsCuqMLSuCD4PIJkp5Uk2z6TCMRCgYVuhRo5cYQY3+1xXTQkSlPtkpGHuvWMjS2KTeVQXxkXRACMbM6A== +"@types/commander@^2.12.2": + version "2.12.2" + resolved "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" + integrity sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q== + dependencies: + commander "*" + "@types/compression@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" @@ -7112,6 +7127,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.6": + version "9.0.13" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -7169,6 +7191,24 @@ resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== +"@types/html-minifier@*": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-4.0.1.tgz#9486ffc144f8d7b8f75b07939c500ac3d73617a0" + integrity sha512-6u58FWQbWP45bsxVeMJo0yk2LEsjjZsCwn0JDe/i5Edk3L+b9TR5eZ2FGaMCrLdtGYpME5AGxUqv8o+3hWKogw== + dependencies: + "@types/clean-css" "*" + "@types/relateurl" "*" + "@types/uglify-js" "*" + +"@types/html-webpack-plugin@*": + version "3.2.6" + resolved "https://registry.npmjs.org/@types/html-webpack-plugin/-/html-webpack-plugin-3.2.6.tgz#07951aaf0fa260dbf626f9644f1d13106d537625" + integrity sha512-U8uJSvlf9lwrKG6sKFnMhqY4qJw2QXad+PHlX9sqEXVUMilVt96aVvFde73tzsdXD+QH9JS6kEytuGO2JcYZog== + dependencies: + "@types/html-minifier" "*" + "@types/tapable" "^1" + "@types/webpack" "^4" + "@types/http-assert@*": version "1.5.1" resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" @@ -7692,6 +7732,17 @@ dependencies: "@types/react" "*" +"@types/react-dev-utils@^9.0.4": + version "9.0.8" + resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.8.tgz#7e4d63d1e1c71cd236c9055bc0c0dbaa3772bcf9" + integrity sha512-H/R8BvtCf9BUWPLL9a2agUWWBOKQQPkBIe5osdrgGaDJHZggQRiNN3emH16rQkzm5zi6TVuslOFrYrfMx+QTjw== + dependencies: + "@types/eslint" "*" + "@types/express" "*" + "@types/html-webpack-plugin" "*" + "@types/webpack" "^4" + "@types/webpack-dev-server" "^3" + "@types/react-dom@*", "@types/react-dom@>=16.9.0": version "17.0.9" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -7802,6 +7853,11 @@ resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.2.tgz#a1ad747fbcc6726643a8eb2c42bb804bbf34ce02" integrity sha512-i7KOGl6xdkfpq5+p2ooC+/XFIRUMkYymZ29SD8p+Ko9lesKGUsh6860ey3YM7Y+ZG7kEDGcjzyLO3sOhozqEeA== +"@types/relateurl@*": + version "0.2.29" + resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6" + integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg== + "@types/request@^2.47.1": version "2.48.5" resolved "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" @@ -7863,6 +7919,13 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-7.3.8.tgz#508a27995498d7586dcecd77c25e289bfaf90c59" integrity sha512-D/2EJvAlCEtYFEYmmlGwbGXuK886HzyCc3nZX/tkFTQdEU8jZDAgiv08P162yB17y4ZXZoq7yFAnW4GDBb9Now== +"@types/serve-handler@^6.1.0": + version "6.1.1" + resolved "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.1.tgz#629dc9a62b201ab79a216e1e46e162aa4c8d1455" + integrity sha512-bIwSmD+OV8w0t2e7EWsuQYlGoS1o5aEdVktgkXaa43Zm0qVWi21xaSRb3DQA1UXD+DJ5bRq1Rgu14ZczB+CjIQ== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.9" resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -8076,6 +8139,17 @@ "@types/expect" "^1.20.4" "@types/node" "*" +"@types/webpack-dev-server@^3": + version "3.11.6" + resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.6.tgz#d8888cfd2f0630203e13d3ed7833a4d11b8a34dc" + integrity sha512-XCph0RiiqFGetukCTC3KVnY1jwLcZ84illFRMbyFzCcWl90B/76ew0tSqF46oBhnLC4obNDG7dMO0JfTN0MgMQ== + dependencies: + "@types/connect-history-api-fallback" "*" + "@types/express" "*" + "@types/serve-static" "*" + "@types/webpack" "^4" + http-proxy-middleware "^1.0.0" + "@types/webpack-dev-server@^3.11.5": version "3.11.5" resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.5.tgz#f4a254a3dd0667c8ee4af90d42afdb4ad1d607f3" @@ -8092,6 +8166,11 @@ resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== +"@types/webpack-env@^1.15.3": + version "1.16.3" + resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a" + integrity sha512-9gtOPPkfyNoEqCQgx4qJKkuNm/x0R2hKR7fdl7zvTJyHnIisuE/LfvXOsYWL0o3qq6uiBnKZNNNzi3l0y/X+xw== + "@types/webpack-sources@*": version "0.1.6" resolved "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.6.tgz#3d21dfc2ec0ad0c77758e79362426a9ba7d7cbcb" @@ -11289,6 +11368,11 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== +commander@*: + version "8.3.0" + resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + commander@7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" @@ -11507,6 +11591,11 @@ contains-path@^0.1.0: resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -14364,6 +14453,13 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== +fast-url-parser@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" + integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= + dependencies: + punycode "^1.3.2" + fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -20342,6 +20438,18 @@ mime-db@1.49.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.32" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" @@ -22227,7 +22335,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.2: +path-is-inside@1.0.2, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -22264,6 +22372,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -23428,7 +23541,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -23577,6 +23690,11 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" +range-parser@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -25428,6 +25546,20 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" +serve-handler@^6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" + integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== + dependencies: + bytes "3.0.0" + content-disposition "0.5.2" + fast-url-parser "1.1.3" + mime-types "2.1.18" + minimatch "3.0.4" + path-is-inside "1.0.2" + path-to-regexp "2.2.1" + range-parser "1.2.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" From 7a3c2f930118dcc990fe94a1ac3f2dca0bf44d54 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 11 Nov 2021 16:13:31 +0100 Subject: [PATCH 022/118] fix(techdocs-cli): wait for child process to finish Signed-off-by: Camila Belo --- .../techdocs-cli/src/commands/serve/serve.ts | 7 ++++- packages/techdocs-cli/src/e2e.test.ts | 11 +++----- packages/techdocs-cli/src/lib/run.ts | 27 +++++++------------ 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 62682ae1eb..763ef76118 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -122,5 +122,10 @@ export default async function serve(cmd: Command) { ); }); - await waitForSignal([mkdocsChildProcess]); + try { + await waitForSignal([mkdocsChildProcess]); + process.exit(0); + } catch { + process.exit(1); + } } diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts index 42afb5a1fa..45fa112bde 100644 --- a/packages/techdocs-cli/src/e2e.test.ts +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -56,13 +56,10 @@ describe('end-to-end', () => { it('can serve in backstage', async () => { jest.setTimeout(10000); - const proc = await executeTechDocsCliCommand( - ['serve', '--no-docker', '--mkdocs-port=8888'], - { - cwd: FIXTURE_DIR, - killAfter: 8000, - }, - ); + const proc = await executeTechDocsCliCommand(['serve', '--no-docker'], { + cwd: FIXTURE_DIR, + killAfter: 8000, + }); expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); expect(proc.combinedStdOutErr).toContain('Serving docs in Backstage at'); diff --git a/packages/techdocs-cli/src/lib/run.ts b/packages/techdocs-cli/src/lib/run.ts index cbc6227589..9e2abeb617 100644 --- a/packages/techdocs-cli/src/lib/run.ts +++ b/packages/techdocs-cli/src/lib/run.ts @@ -49,7 +49,6 @@ export const run = async ( const childProcess = spawn(name, args, { stdio: stdio, - shell: true, ...options, env, }); @@ -72,6 +71,14 @@ export async function waitForSignal( ): Promise { const promises: Array> = []; + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + childProcesses.forEach(childProcess => { + childProcess.kill(); + }); + }); + } + childProcesses.forEach(childProcess => { if (typeof childProcess.exitCode === 'number') { if (childProcess.exitCode) { @@ -80,24 +87,10 @@ export async function waitForSignal( return; } - for (const signal of ['SIGINT', 'SIGTERM'] as const) { - process.on(signal, () => { - childProcess.kill(signal); - // exit instead of resolve. The process is shutting down and resolving a promise here logs an error - process.exit(); - }); - } - promises.push( new Promise((resolve, reject) => { - childProcess.once('error', error => reject(error)); - childProcess.once('exit', code => { - if (code) { - reject(new Error(`Non zero exit code from child process`)); - } else { - resolve(); - } - }); + childProcess.once('error', reject); + childProcess.once('exit', resolve); }), ); }); From 0456b73da8a73e26036c16d029ea2dda40a21a5f Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 11 Nov 2021 19:09:30 +0100 Subject: [PATCH 023/118] fix(techdocs-cli): config file for development build Signed-off-by: Camila Belo --- package.json | 3 ++- packages/embedded-techdocs-app/app-config.dev.yaml | 5 +++++ packages/embedded-techdocs-app/package.json | 1 + packages/techdocs-cli/package.json | 1 + packages/techdocs-cli/scripts/build.sh | 6 +++++- packages/techdocs-cli/src/commands/serve/serve.ts | 9 ++++----- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4fc9b2352d..c0c68add37 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", - "techdocs-cli:dev": "TECHDOCS_CLI_DEV_MODE=true packages/techdocs-cli/bin/techdocs-cli", + "techdocs-cli": "yarn workspace @techdocs/cli build && packages/techdocs-cli/bin/techdocs-cli", + "techdocs-cli:dev": "yarn workspace @techdocs/cli build:dev && TECHDOCS_CLI_DEV_MODE=true packages/techdocs-cli/bin/techdocs-cli", "prepare": "husky install", "lock:check": "yarn-lock-check" }, diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml index c40c473aa2..2d1bad2808 100644 --- a/packages/embedded-techdocs-app/app-config.dev.yaml +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -1,7 +1,12 @@ # NOTE: This file is used for testing techdocs-cli locally +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + backend: baseUrl: http://localhost:7000 techdocs: + builder: 'external' requestUrl: http://localhost:7000/api diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json index 3bd52f39e2..12ac80ab92 100644 --- a/packages/embedded-techdocs-app/package.json +++ b/packages/embedded-techdocs-app/package.json @@ -40,6 +40,7 @@ "scripts": { "start": "backstage-cli app:serve --config ./app-config.yaml --config ./app-config.dev.yaml", "build": "backstage-cli app:build --config ./app-config.yaml", + "build:dev": "backstage-cli app:build --config ./app-config.dev.yaml", "clean": "backstage-cli clean", "test": "backstage-cli test", "lint": "backstage-cli lint", diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index a7ac3c3d80..5367f0a151 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -22,6 +22,7 @@ "scripts": { "start": "nodemon --", "build": "./scripts/build.sh", + "build:dev": "TECHDOCS_CLI_DEV_MODE=true yarn build", "clean": "backstage-cli clean", "lint": "backstage-cli lint", "test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts", diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh index a56be00206..7aec589b13 100755 --- a/packages/techdocs-cli/scripts/build.sh +++ b/packages/techdocs-cli/scripts/build.sh @@ -27,7 +27,11 @@ TECHDOCS_PREVIEW_DEST=dist/techdocs-preview-bundle # Build the embedded-techdocs-app pushd $EMBEDDED_TECHDOCS_APP_PATH >/dev/null -yarn build +if [[ $TECHDOCS_CLI_DEV_MODE == "true" ]]; then + yarn build:dev +else + yarn build +fi popd >/dev/null cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 763ef76118..5aaf601c72 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -98,9 +98,10 @@ export default async function serve(cmd: Command) { 'techdocs-preview-bundle', ); + const port = isDevMode ? backstageBackendPort : backstagePort; const httpServer = new HTTPServer( techdocsPreviewBundlePath, - isDevMode ? backstageBackendPort : backstagePort, + port, cmd.mkdocsPort, cmd.verbose, ); @@ -114,11 +115,9 @@ export default async function serve(cmd: Command) { }) .then(() => { // The last three things default/component/local/ don't matter. They can be anything. - openBrowser( - `http://localhost:${backstagePort}/docs/default/component/local/`, - ); + openBrowser(`http://localhost:${port}/docs/default/component/local/`); logger.info( - `Serving docs in Backstage at http://localhost:${backstagePort}/docs/default/component/local/\nOpening browser.`, + `Serving docs in Backstage at http://localhost:${port}/docs/default/component/local/\nOpening browser.`, ); }); From c2f387880fa31d26a0c620906f21f84665e75397 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 11 Nov 2021 20:38:18 +0100 Subject: [PATCH 024/118] chore(techdocs-cli): add backstage app defaults Signed-off-by: Camila Belo --- packages/embedded-techdocs-app/package.json | 1 + packages/embedded-techdocs-app/src/App.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json index 12ac80ab92..7901d46fee 100644 --- a/packages/embedded-techdocs-app/package.json +++ b/packages/embedded-techdocs-app/package.json @@ -4,6 +4,7 @@ "private": true, "bundled": true, "dependencies": { + "@backstage/app-defaults": "^0.1.0", "@backstage/catalog-model": "^0.9.5", "@backstage/cli": "^0.8.0", "@backstage/config": "^0.1.10", diff --git a/packages/embedded-techdocs-app/src/App.tsx b/packages/embedded-techdocs-app/src/App.tsx index 81a495ca33..00b232d8e1 100644 --- a/packages/embedded-techdocs-app/src/App.tsx +++ b/packages/embedded-techdocs-app/src/App.tsx @@ -16,7 +16,8 @@ import React from 'react'; import { Navigate, Route } from 'react-router'; -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; import { CatalogEntityPage } from '@backstage/plugin-catalog'; import { From 8acb23b29f72e29913069386783985bab262d0bf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 17 Oct 2021 13:45:36 +0200 Subject: [PATCH 025/118] cli: initial create command Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 42 ++++++++++++++++++++++ packages/cli/src/commands/index.ts | 18 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 packages/cli/src/commands/create/create.ts diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts new file mode 100644 index 0000000000..ed4bcc9c20 --- /dev/null +++ b/packages/cli/src/commands/create/create.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; + +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 || !value) { + throw new Error( + `Invalid option '${str}', must be of the format =`, + ); + } + options[key] = value; + } + + return options; +} + +export default async (cmd: Command) => { + const selected = cmd.opts().select; + console.log('DEBUG: selected =', selected); + + const options = parseOptions(cmd.opts().option); + console.log('DEBUG: options =', options); +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4e01a8ca9f..06832c466b 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -75,6 +75,24 @@ 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], + [], + ) + .action(lazy(() => import('./create/create').then(m => m.default))); + program .command('create-plugin') .option( From e607456dbcb77d779ee28af8c1980ca4f9e713be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 17 Oct 2021 16:59:59 +0200 Subject: [PATCH 026/118] cli: create command factory foundations Signed-off-by: Patrik Oldsberg --- .../src/commands/create/FactoryRegistry.ts | 76 +++++++++++++++++++ packages/cli/src/commands/create/create.ts | 13 +++- .../create/factories/frontendPlugin.ts | 46 +++++++++++ .../src/commands/create/factories/index.ts | 17 +++++ packages/cli/src/commands/create/types.ts | 34 +++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/create/FactoryRegistry.ts create mode 100644 packages/cli/src/commands/create/factories/frontendPlugin.ts create mode 100644 packages/cli/src/commands/create/factories/index.ts create mode 100644 packages/cli/src/commands/create/types.ts diff --git a/packages/cli/src/commands/create/FactoryRegistry.ts b/packages/cli/src/commands/create/FactoryRegistry.ts new file mode 100644 index 0000000000..0f48acc337 --- /dev/null +++ b/packages/cli/src/commands/create/FactoryRegistry.ts @@ -0,0 +1,76 @@ +/* + * 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 { AnyFactory } from './types'; +import * as factories from './factories'; +import partition from 'lodash/partition'; + +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> { + const [hasAnswers, needsAnswers] = partition( + factory.options, + option => option.name in provided, + ); + + 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}`); + } + } + } + + const answers = await inquirer.prompt(needsAnswers); + + return { ...provided, ...answers }; + } +} diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index ed4bcc9c20..2397e7ec58 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -15,6 +15,7 @@ */ import { Command } from 'commander'; +import { FactoryRegistry } from './FactoryRegistry'; function parseOptions(optionStrings: string[]): Record { const options: Record = {}; @@ -34,9 +35,13 @@ function parseOptions(optionStrings: string[]): Record { } export default async (cmd: Command) => { - const selected = cmd.opts().select; - console.log('DEBUG: selected =', selected); + const factory = await FactoryRegistry.interactiveSelect(cmd.opts().select); - const options = parseOptions(cmd.opts().option); - console.log('DEBUG: options =', options); + const providedOptions = parseOptions(cmd.opts().option); + const options = await FactoryRegistry.populateOptions( + factory, + providedOptions, + ); + + await factory.create(options); }; diff --git a/packages/cli/src/commands/create/factories/frontendPlugin.ts b/packages/cli/src/commands/create/factories/frontendPlugin.ts new file mode 100644 index 0000000000..195953e780 --- /dev/null +++ b/packages/cli/src/commands/create/factories/frontendPlugin.ts @@ -0,0 +1,46 @@ +/* + * 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 { createFactory } from '../types'; + +type Options = { + id: string; +}; + +export const frontendPlugin = createFactory({ + name: 'plugin', + description: 'A new frontend plugin', + options: [ + { + type: 'input', + name: 'id', + message: 'Enter an ID for the plugin', + validate: (value: string) => { + if (!value) { + return 'Please enter an ID for 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; + }, + }, + ], + async create(options: Options) { + console.log( + `Creating ${this.name} with options ${JSON.stringify(options)}`, + ); + }, +}); diff --git a/packages/cli/src/commands/create/factories/index.ts b/packages/cli/src/commands/create/factories/index.ts new file mode 100644 index 0000000000..4375ba2a45 --- /dev/null +++ b/packages/cli/src/commands/create/factories/index.ts @@ -0,0 +1,17 @@ +/* + * 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'; diff --git a/packages/cli/src/commands/create/types.ts b/packages/cli/src/commands/create/types.ts new file mode 100644 index 0000000000..e5252df6bc --- /dev/null +++ b/packages/cli/src/commands/create/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type AnyOptions = Record; + +export interface Factory { + name: string; + description: string; + options: ReadonlyArray & { name: string }>; + create(options: Options): Promise; +} + +export type AnyFactory = Factory; + +export function createFactory( + config: Factory, +): AnyFactory { + return config as AnyFactory; +} From 2ca3f5ff4c3585505a72fdd285382c44451b51d6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 11:22:30 +0100 Subject: [PATCH 027/118] cli: added support for dynamically discovering create options Signed-off-by: Patrik Oldsberg --- .../src/commands/create/FactoryRegistry.ts | 42 ++++++++++++------- .../create/factories/frontendPlugin.ts | 29 ++++++++++++- packages/cli/src/commands/create/types.ts | 3 +- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/create/FactoryRegistry.ts b/packages/cli/src/commands/create/FactoryRegistry.ts index 0f48acc337..a959896bd0 100644 --- a/packages/cli/src/commands/create/FactoryRegistry.ts +++ b/packages/cli/src/commands/create/FactoryRegistry.ts @@ -53,24 +53,36 @@ export class FactoryRegistry { factory: AnyFactory, provided: Record, ): Promise> { - const [hasAnswers, needsAnswers] = partition( - factory.options, - option => option.name in provided, - ); + let currentOptions = provided; - 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}`); - } - } + if (factory.optionsDiscovery) { + const discoveredOptions = await factory.optionsDiscovery(); + currentOptions = { + ...currentOptions, + ...(discoveredOptions as Record), + }; } - const answers = await inquirer.prompt(needsAnswers); + if (factory.optionsPrompts) { + const [hasAnswers, needsAnswers] = partition( + factory.optionsPrompts, + option => option.name in currentOptions, + ); - return { ...provided, ...answers }; + 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, currentOptions); + } + + return currentOptions; } } diff --git a/packages/cli/src/commands/create/factories/frontendPlugin.ts b/packages/cli/src/commands/create/factories/frontendPlugin.ts index 195953e780..35e18b9e09 100644 --- a/packages/cli/src/commands/create/factories/frontendPlugin.ts +++ b/packages/cli/src/commands/create/factories/frontendPlugin.ts @@ -14,20 +14,27 @@ * limitations under the License. */ +import { paths } from '../../../lib/paths'; +import { getCodeownersFilePath, parseOwnerIds } from '../../../lib/codeowners'; import { createFactory } from '../types'; type Options = { id: string; + owner?: string; + codeOwnersPath?: string; }; export const frontendPlugin = createFactory({ name: 'plugin', description: 'A new frontend plugin', - options: [ + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [ { type: 'input', name: 'id', - message: 'Enter an ID for the plugin', + message: 'Enter an ID for the plugin [required]', validate: (value: string) => { if (!value) { return 'Please enter an ID for the plugin'; @@ -37,6 +44,24 @@ export const frontendPlugin = createFactory({ return true; }, }, + { + type: 'input', + name: 'owner', + message: 'Enter an owner of the plugin 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; + }, + }, ], async create(options: Options) { console.log( diff --git a/packages/cli/src/commands/create/types.ts b/packages/cli/src/commands/create/types.ts index e5252df6bc..a797664e37 100644 --- a/packages/cli/src/commands/create/types.ts +++ b/packages/cli/src/commands/create/types.ts @@ -21,7 +21,8 @@ export type AnyOptions = Record; export interface Factory { name: string; description: string; - options: ReadonlyArray & { name: string }>; + optionsDiscovery?(): Promise>; + optionsPrompts?: ReadonlyArray & { name: string }>; create(options: Options): Promise; } From 55e58c55684109303b8559db2ea578bf88e72c5d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 11:24:40 +0100 Subject: [PATCH 028/118] cli: moved bulk of create implementation over to lib Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 2 +- packages/cli/src/{commands => lib}/create/FactoryRegistry.ts | 0 .../src/{commands => lib}/create/factories/frontendPlugin.ts | 0 packages/cli/src/{commands => lib}/create/factories/index.ts | 0 packages/cli/src/{commands => lib}/create/types.ts | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename packages/cli/src/{commands => lib}/create/FactoryRegistry.ts (100%) rename packages/cli/src/{commands => lib}/create/factories/frontendPlugin.ts (100%) rename packages/cli/src/{commands => lib}/create/factories/index.ts (100%) rename packages/cli/src/{commands => lib}/create/types.ts (100%) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 2397e7ec58..4c1ca68557 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -15,7 +15,7 @@ */ import { Command } from 'commander'; -import { FactoryRegistry } from './FactoryRegistry'; +import { FactoryRegistry } from '../../lib/create/FactoryRegistry'; function parseOptions(optionStrings: string[]): Record { const options: Record = {}; diff --git a/packages/cli/src/commands/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts similarity index 100% rename from packages/cli/src/commands/create/FactoryRegistry.ts rename to packages/cli/src/lib/create/FactoryRegistry.ts diff --git a/packages/cli/src/commands/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts similarity index 100% rename from packages/cli/src/commands/create/factories/frontendPlugin.ts rename to packages/cli/src/lib/create/factories/frontendPlugin.ts diff --git a/packages/cli/src/commands/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts similarity index 100% rename from packages/cli/src/commands/create/factories/index.ts rename to packages/cli/src/lib/create/factories/index.ts diff --git a/packages/cli/src/commands/create/types.ts b/packages/cli/src/lib/create/types.ts similarity index 100% rename from packages/cli/src/commands/create/types.ts rename to packages/cli/src/lib/create/types.ts From 3ca0d3368eb6ec86690941ef7300b312bdfcbd5e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 11:28:54 +0100 Subject: [PATCH 029/118] cli: display create prompt messages in blue and errors in red Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/create/FactoryRegistry.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts index a959896bd0..1b25f5950a 100644 --- a/packages/cli/src/lib/create/FactoryRegistry.ts +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import chalk from 'chalk'; import inquirer from 'inquirer'; import { AnyFactory } from './types'; import * as factories from './factories'; @@ -80,7 +81,22 @@ export class FactoryRegistry { } } - currentOptions = await inquirer.prompt(needsAnswers, currentOptions); + currentOptions = await inquirer.prompt( + needsAnswers.map(option => ({ + ...option, + message: option.message && chalk.blue(option.message), + validate: + option.validate && + (async (...args) => { + const result = await option.validate!(...args); + if (typeof result === 'string') { + return chalk.red(result); + } + return result; + }), + })), + currentOptions, + ); } return currentOptions; From eec57229eb79fd3ba4004fcbca4b557401e6ea34 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 12 Nov 2021 11:36:22 +0100 Subject: [PATCH 030/118] fix(techdocs-cli): remove quote from volume Ref: https://github.com/backstage/techdocs-cli/pull/162 Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- packages/techdocs-cli/src/lib/mkdocsServer.test.ts | 3 +-- packages/techdocs-cli/src/lib/mkdocsServer.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts index e06541a3e8..0167dec12b 100644 --- a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts +++ b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts @@ -32,12 +32,11 @@ describe('runMkdocsServer', () => { it('should run docker directly by default', async () => { await runMkdocsServer({}); - const quotedCwd = `"${process.cwd()}":/content`; expect(run).toHaveBeenCalledWith( 'docker', expect.arrayContaining([ 'run', - quotedCwd, + `${process.cwd()}:/content`, '8000:8000', 'serve', '--dev-addr', diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts index d35edc41b5..30e9b46451 100644 --- a/packages/techdocs-cli/src/lib/mkdocsServer.ts +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -37,7 +37,7 @@ export const runMkdocsServer = async (options: { '-w', '/content', '-v', - `"${process.cwd()}":/content`, + `${process.cwd()}:/content`, '-p', `${port}:${port}`, dockerImage, From 84936dbcbe9fac0244c71ca90da7404a5e5d2a78 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 12:03:23 +0100 Subject: [PATCH 031/118] cli: cmd options and discovery for additional creation context Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 37 +++++++++++++++++-- packages/cli/src/commands/index.ts | 6 +++ .../lib/create/factories/frontendPlugin.ts | 8 ++-- packages/cli/src/lib/create/types.ts | 15 +++++++- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 4c1ca68557..07378579d2 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -14,8 +14,11 @@ * limitations under the License. */ +import fs from 'fs-extra'; import { Command } from 'commander'; import { FactoryRegistry } from '../../lib/create/FactoryRegistry'; +import { paths } from '../../lib/paths'; +import { assertError } from '@backstage/errors'; function parseOptions(optionStrings: string[]): Record { const options: Record = {}; @@ -35,13 +38,41 @@ function parseOptions(optionStrings: string[]): Record { } export default async (cmd: Command) => { - const factory = await FactoryRegistry.interactiveSelect(cmd.opts().select); + const cmdOpts = cmd.opts(); - const providedOptions = parseOptions(cmd.opts().option); + const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select); + + const providedOptions = parseOptions(cmdOpts.option); const options = await FactoryRegistry.populateOptions( factory, providedOptions, ); - await factory.create(options); + const rootPackageJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + const isMonoRepo = Boolean(rootPackageJson.workspaces); + + 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; + } + } + + await factory.create(options, { + isMonoRepo, + defaultVersion, + scope: cmdOpts.scope.replace(/^@/, ''), + npmRegistry: cmdOpts.npmRegistry, + private: Boolean(cmdOpts.private), + }); }; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 06832c466b..a37d5e9ffe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -91,6 +91,12 @@ export function registerCommands(program: CommanderStatic) { (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 diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index 35e18b9e09..1b13cff13d 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -16,7 +16,7 @@ import { paths } from '../../../lib/paths'; import { getCodeownersFilePath, parseOwnerIds } from '../../../lib/codeowners'; -import { createFactory } from '../types'; +import { createFactory, CreateContext } from '../types'; type Options = { id: string; @@ -63,9 +63,11 @@ export const frontendPlugin = createFactory({ }, }, ], - async create(options: Options) { + async create(options: Options, context: CreateContext) { console.log( - `Creating ${this.name} with options ${JSON.stringify(options)}`, + `Creating ${this.name} with options ${JSON.stringify( + options, + )} and context ${JSON.stringify(context)}`, ); }, }); diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts index a797664e37..82bdd4978a 100644 --- a/packages/cli/src/lib/create/types.ts +++ b/packages/cli/src/lib/create/types.ts @@ -16,6 +16,19 @@ 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; +} + export type AnyOptions = Record; export interface Factory { @@ -23,7 +36,7 @@ export interface Factory { description: string; optionsDiscovery?(): Promise>; optionsPrompts?: ReadonlyArray & { name: string }>; - create(options: Options): Promise; + create(options: Options, context?: CreateContext): Promise; } export type AnyFactory = Factory; From 14e8162d199cbf80dd1e4dfcce6006758e6c89ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 13:08:25 +0100 Subject: [PATCH 032/118] cli: added temp dir utility to create context Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 36 +++++++++++++++++----- packages/cli/src/lib/create/types.ts | 3 ++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 07378579d2..d39fa2b9d0 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -14,7 +14,9 @@ * 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'; @@ -68,11 +70,31 @@ export default async (cmd: Command) => { } } - await factory.create(options, { - isMonoRepo, - defaultVersion, - scope: cmdOpts.scope.replace(/^@/, ''), - npmRegistry: cmdOpts.npmRegistry, - private: Boolean(cmdOpts.private), - }); + const tempDirs = new Array(); + async function createTemporaryDirectory(name: string): Promise { + const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name)); + tempDirs.push(dir); + return dir; + } + + try { + await factory.create(options, { + isMonoRepo, + defaultVersion, + scope: cmdOpts.scope.replace(/^@/, ''), + npmRegistry: cmdOpts.npmRegistry, + private: Boolean(cmdOpts.private), + createTemporaryDirectory, + }); + } 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/lib/create/types.ts b/packages/cli/src/lib/create/types.ts index 82bdd4978a..629506b5f7 100644 --- a/packages/cli/src/lib/create/types.ts +++ b/packages/cli/src/lib/create/types.ts @@ -27,6 +27,9 @@ export interface CreateContext { 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; } export type AnyOptions = Record; From 867ea81d15f7a6bb8b5e05b55e432eb3c932b7ec Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Fri, 12 Nov 2021 14:47:31 +0100 Subject: [PATCH 033/118] Add changeset Signed-off-by: Ben Lambert --- .changeset/tender-gorillas-peel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-gorillas-peel.md 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 From e04cce9cdb5cf02e7c9748c5bb95d9b59391e421 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 15:22:11 +0100 Subject: [PATCH 034/118] cli: wrap create factories up in a bit more built-in logging Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 28 ++++++++++++++++++++++ packages/cli/src/lib/create/types.ts | 3 +++ 2 files changed, 31 insertions(+) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index d39fa2b9d0..7004341c3e 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -21,6 +21,7 @@ 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 = {}; @@ -77,7 +78,11 @@ export default async (cmd: Command) => { return dir; } + let modified = false; try { + Task.log(); + Task.log(`Creating new ${factory.name}`); + await factory.create(options, { isMonoRepo, defaultVersion, @@ -85,7 +90,30 @@ export default async (cmd: Command) => { 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 { diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts index 629506b5f7..6a45067e9b 100644 --- a/packages/cli/src/lib/create/types.ts +++ b/packages/cli/src/lib/create/types.ts @@ -30,6 +30,9 @@ export interface CreateContext { /** 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; From f8adebde77b0a6c299f1463f4a759942fab7be55 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 15:23:32 +0100 Subject: [PATCH 035/118] cli: build out task lib a bit with command execution, adding deps Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/tasks.ts | 81 +++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index e17015d6cf..691e77bd17 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -18,9 +18,14 @@ 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; @@ -42,11 +47,11 @@ export class Task { 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.stdout.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( @@ -122,3 +154,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}`); + } +} From 602cf4d80dabe6a24e4585da37b9a0af09a3b320 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 15:24:08 +0100 Subject: [PATCH 036/118] cli: run templating tasks in strict mode Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/tasks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 691e77bd17..9e67f7f30c 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -117,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 }, { From 248c8eaa25af0f4147fc383fe553765068530f44 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 15:33:45 +0100 Subject: [PATCH 037/118] cli: allow empty create option values Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 7004341c3e..946918d77c 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -29,7 +29,7 @@ function parseOptions(optionStrings: string[]): Record { for (const str of optionStrings) { const [key] = str.split('=', 1); const value = str.slice(key.length + 1); - if (!key || !value) { + if (!key || str[key.length] !== '=') { throw new Error( `Invalid option '${str}', must be of the format =`, ); From 7b8d432de5842a420ca711e5895462118176ed12 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 15:39:17 +0100 Subject: [PATCH 038/118] cli: complete frontendPlugin create factory implementation Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/codeowners/codeowners.ts | 2 +- .../lib/create/factories/frontendPlugin.ts | 144 +++++++++++++++++- 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 563bd1052d..b3fac12109 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -55,7 +55,7 @@ export function isValidSingleOwnerId(id: string): boolean { } export function parseOwnerIds( - spaceSeparatedOwnerIds: string, + spaceSeparatedOwnerIds: string | undefined, ): string[] | undefined { if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') { return undefined; diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index 1b13cff13d..a85403e8ed 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -14,9 +14,20 @@ * limitations under the License. */ -import { paths } from '../../../lib/paths'; -import { getCodeownersFilePath, parseOwnerIds } from '../../../lib/codeowners'; +import fs from 'fs-extra'; +import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; +import chalk from 'chalk'; +import { paths } from '../../paths'; +import { + addCodeownersEntry, + getCodeownersFilePath, + parseOwnerIds, +} from '../../codeowners'; import { createFactory, CreateContext } from '../types'; +import { Lockfile } from '../../versioning'; +import { addPackageDependency, Task, templatingTask } from '../../tasks'; +import { createPackageVersionProvider } from '../../version'; type Options = { id: string; @@ -63,11 +74,130 @@ export const frontendPlugin = createFactory({ }, }, ], - async create(options: Options, context: CreateContext) { - console.log( - `Creating ${this.name} with options ${JSON.stringify( - options, - )} and context ${JSON.stringify(context)}`, + async create(options: Options, ctx: CreateContext) { + const { id } = options; + + const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + const extensionName = `${upperFirst(camelCase(id))}Page`; + + const pluginDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + let lockfile: Lockfile | undefined; + try { + lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + } catch (error) { + console.warn(`No yarn.lock available, ${error}`); + } + + Task.section('Validating prerequisites'); + const shortPluginDir = pluginDir.replace(`${paths.targetRoot}/`, ''); + await Task.forItem('availability', shortPluginDir, async () => { + if (await fs.pathExists(pluginDir)) { + throw new Error( + `A plugin with the same 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-plugin-${id}`); + }); + + Task.section('Executing plugin template'); + await templatingTask( + paths.resolveOwn('templates/default-plugin'), + tempDir, + { + id, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + extensionName, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + createPackageVersionProvider(lockfile), ); + + Task.section('Installing plugin'); + await Task.forItem('moving', shortPluginDir, async () => { + await fs.move(tempDir, pluginDir).catch(error => { + throw new Error( + `Failed to move plugin from ${tempDir} to ${pluginDir}, ${error.message}`, + ); + }); + }); + + ctx.markAsModified(); + + 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.codeOwnersPath && options.owner) { + const ownerIds = parseOwnerIds(options.owner); + if (ownerIds && ownerIds.length > 0) { + await addCodeownersEntry( + options.codeOwnersPath, + `/plugins/${id}`, + ownerIds, + ); + } + } + + await Task.forCommand('yarn install', { cwd: pluginDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: pluginDir, + optional: true, + }); }, }); From 1c0ac6291cdaa278aa58cf00592e6ab7b562e92d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 16:00:54 +0100 Subject: [PATCH 039/118] cli: added create backend plugin factory Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/backendPlugin.ts | 164 ++++++++++++++++++ .../cli/src/lib/create/factories/index.ts | 1 + 2 files changed, 165 insertions(+) create mode 100644 packages/cli/src/lib/create/factories/backendPlugin.ts 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..9cf78900a7 --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -0,0 +1,164 @@ +/* + * 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 camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; +import chalk from 'chalk'; +import { paths } from '../../paths'; +import { + addCodeownersEntry, + getCodeownersFilePath, + parseOwnerIds, +} from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Lockfile } from '../../versioning'; +import { addPackageDependency, Task, templatingTask } from '../../tasks'; +import { createPackageVersionProvider } from '../../version'; + +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: [ + { + type: 'input', + name: 'id', + message: 'Enter an ID for the plugin [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter an ID for 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; + }, + }, + { + type: 'input', + name: 'owner', + message: 'Enter an owner of the plugin 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; + }, + }, + ], + async create(options: Options, ctx: CreateContext) { + const id = `${options.id}-backend`; + const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + + const pluginDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + let lockfile: Lockfile | undefined; + try { + lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + } catch (error) { + console.warn(`No yarn.lock available, ${error}`); + } + + Task.section('Validating prerequisites'); + const shortPluginDir = pluginDir.replace(`${paths.targetRoot}/`, ''); + await Task.forItem('availability', shortPluginDir, async () => { + if (await fs.pathExists(pluginDir)) { + throw new Error( + `A backend plugin with the same 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-plugin-${id}`); + }); + + Task.section('Executing plugin template'); + await templatingTask( + paths.resolveOwn('templates/default-backend-plugin'), + tempDir, + { + id, + name, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + createPackageVersionProvider(lockfile), + ); + + Task.section('Installing plugin'); + await Task.forItem('moving', shortPluginDir, async () => { + await fs.move(tempDir, pluginDir).catch(error => { + throw new Error( + `Failed to move plugin from ${tempDir} to ${pluginDir}, ${error.message}`, + ); + }); + }); + + ctx.markAsModified(); + + 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.codeOwnersPath && options.owner) { + const ownerIds = parseOwnerIds(options.owner); + if (ownerIds && ownerIds.length > 0) { + await addCodeownersEntry( + options.codeOwnersPath, + `/plugins/${id}`, + ownerIds, + ); + } + } + + await Task.forCommand('yarn install', { cwd: pluginDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: pluginDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts index 4375ba2a45..2e16979e50 100644 --- a/packages/cli/src/lib/create/factories/index.ts +++ b/packages/cli/src/lib/create/factories/index.ts @@ -15,3 +15,4 @@ */ export { frontendPlugin } from './frontendPlugin'; +export { backendPlugin } from './backendPlugin'; From 142cd41b7e351c20f23a6a9f611586b28bcf81ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 16:08:32 +0100 Subject: [PATCH 040/118] cli: refactor create factories to use common prompts Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/backendPlugin.ts | 35 +---------- .../lib/create/factories/common/prompts.ts | 58 +++++++++++++++++++ .../lib/create/factories/frontendPlugin.ts | 35 +---------- packages/cli/src/lib/create/types.ts | 14 +++-- 4 files changed, 70 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/lib/create/factories/common/prompts.ts diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts index 9cf78900a7..d7855d5d68 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -28,6 +28,7 @@ import { createFactory, CreateContext } from '../types'; import { Lockfile } from '../../versioning'; import { addPackageDependency, Task, templatingTask } from '../../tasks'; import { createPackageVersionProvider } from '../../version'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; type Options = { id: string; @@ -41,39 +42,7 @@ export const backendPlugin = createFactory({ optionsDiscovery: async () => ({ codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), }), - optionsPrompts: [ - { - type: 'input', - name: 'id', - message: 'Enter an ID for the plugin [required]', - validate: (value: string) => { - if (!value) { - return 'Please enter an ID for 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; - }, - }, - { - type: 'input', - name: 'owner', - message: 'Enter an owner of the plugin 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; - }, - }, - ], + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], async create(options: Options, ctx: CreateContext) { const id = `${options.id}-backend`; const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; 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/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index a85403e8ed..3fe01b7325 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -28,6 +28,7 @@ import { createFactory, CreateContext } from '../types'; import { Lockfile } from '../../versioning'; import { addPackageDependency, Task, templatingTask } from '../../tasks'; import { createPackageVersionProvider } from '../../version'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; type Options = { id: string; @@ -41,39 +42,7 @@ export const frontendPlugin = createFactory({ optionsDiscovery: async () => ({ codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), }), - optionsPrompts: [ - { - type: 'input', - name: 'id', - message: 'Enter an ID for the plugin [required]', - validate: (value: string) => { - if (!value) { - return 'Please enter an ID for 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; - }, - }, - { - type: 'input', - name: 'owner', - message: 'Enter an owner of the plugin 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; - }, - }, - ], + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], async create(options: Options, ctx: CreateContext) { const { id } = options; diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts index 6a45067e9b..fd2e684e11 100644 --- a/packages/cli/src/lib/create/types.ts +++ b/packages/cli/src/lib/create/types.ts @@ -37,18 +37,20 @@ export interface CreateContext { export type AnyOptions = Record; -export interface Factory { +export type Prompt = DistinctQuestion & { name: string }; + +export interface Factory { name: string; description: string; - optionsDiscovery?(): Promise>; - optionsPrompts?: ReadonlyArray & { name: string }>; - create(options: Options, context?: CreateContext): Promise; + optionsDiscovery?(): Promise>; + optionsPrompts?: ReadonlyArray>; + create(options: TOptions, context?: CreateContext): Promise; } export type AnyFactory = Factory; -export function createFactory( - config: Factory, +export function createFactory( + config: Factory, ): AnyFactory { return config as AnyFactory; } From 06934f2f5275ad11882d1b060c3da874ff6df17e Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 15:10:26 +0000 Subject: [PATCH 041/118] catalog-backend: ensure subqueries are isolated from one another Ensures that independent calls to `parseFilter` are isolated from one another, by always wrapping additional clauses to the query in `andWhere`. I think that the bug in the previous incarnation is strictly theoretical, as long as parseFilters is only called once for a given filter (which today, it is). When authorization lands in catalog-backend though, we'll need to call it multiple times - once to add the authz filters, and once to add the filters requested by the caller, which would expose this bug. Signed-off-by: Mike Lewis --- .changeset/orange-experts-approve.md | 5 ++++ .../src/service/NextEntitiesCatalog.ts | 26 ++++++++----------- 2 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 .changeset/orange-experts-approve.md diff --git a/.changeset/orange-experts-approve.md b/.changeset/orange-experts-approve.md new file mode 100644 index 0000000000..13d1b022da --- /dev/null +++ b/.changeset/orange-experts-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Adjust entity query construction to ensure sub-queries are always isolated from one another. diff --git a/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts index 841e57c921..1c615f862a 100644 --- a/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts @@ -131,29 +131,25 @@ function parseFilter( db: Knex, ): Knex.QueryBuilder { if (isEntitiesSearchFilter(filter)) { - return query.where(function filterFunction() { + return query.andWhere(function filterFunction() { addCondition(this, db, filter); }); } if (isOrEntityFilter(filter)) { - let cumulativeQuery = query; - for (const subFilter of filter.anyOf ?? []) { - cumulativeQuery = cumulativeQuery.orWhere(subQuery => - parseFilter(subFilter, subQuery, db), - ); - } - return cumulativeQuery; + return query.andWhere(function filterFunction() { + for (const subFilter of filter.anyOf ?? []) { + this.orWhere(subQuery => parseFilter(subFilter, subQuery, db)); + } + }); } if (isAndEntityFilter(filter)) { - let cumulativeQuery = query; - for (const subFilter of filter.allOf ?? []) { - cumulativeQuery = cumulativeQuery.andWhere(subQuery => - parseFilter(subFilter, subQuery, db), - ); - } - return cumulativeQuery; + return query.andWhere(function filterFunction() { + for (const subFilter of filter.allOf ?? []) { + this.andWhere(subQuery => parseFilter(subFilter, subQuery, db)); + } + }); } return query; From 0ebcf168d25d04a7ba39b75929fbca273ae6446d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 16:36:57 +0100 Subject: [PATCH 042/118] cli: common helper for executing plugin package templates Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/backendPlugin.ts | 57 +++----------- .../src/lib/create/factories/common/tasks.ts | 76 +++++++++++++++++++ .../lib/create/factories/frontendPlugin.ts | 60 +++------------ 3 files changed, 96 insertions(+), 97 deletions(-) create mode 100644 packages/cli/src/lib/create/factories/common/tasks.ts diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts index d7855d5d68..68c684f6ab 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -16,8 +16,6 @@ import fs from 'fs-extra'; import camelCase from 'lodash/camelCase'; -import upperFirst from 'lodash/upperFirst'; -import chalk from 'chalk'; import { paths } from '../../paths'; import { addCodeownersEntry, @@ -25,10 +23,9 @@ import { parseOwnerIds, } from '../../codeowners'; import { createFactory, CreateContext } from '../types'; -import { Lockfile } from '../../versioning'; -import { addPackageDependency, Task, templatingTask } from '../../tasks'; -import { createPackageVersionProvider } from '../../version'; +import { addPackageDependency, Task } from '../../tasks'; import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; type Options = { id: string; @@ -47,38 +44,14 @@ export const backendPlugin = createFactory({ const id = `${options.id}-backend`; const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; - const pluginDir = ctx.isMonoRepo + const targetDir = ctx.isMonoRepo ? paths.resolveTargetRoot('plugins', id) : paths.resolveTargetRoot(`backstage-plugin-${id}`); - let lockfile: Lockfile | undefined; - try { - lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); - } catch (error) { - console.warn(`No yarn.lock available, ${error}`); - } - - Task.section('Validating prerequisites'); - const shortPluginDir = pluginDir.replace(`${paths.targetRoot}/`, ''); - await Task.forItem('availability', shortPluginDir, async () => { - if (await fs.pathExists(pluginDir)) { - throw new Error( - `A backend plugin with the same 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-plugin-${id}`); - }); - - Task.section('Executing plugin template'); - await templatingTask( - paths.resolveOwn('templates/default-backend-plugin'), - tempDir, - { + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-backend-plugin', + values: { id, name, pluginVar: `${camelCase(id)}Plugin`, @@ -86,20 +59,8 @@ export const backendPlugin = createFactory({ privatePackage: ctx.private, npmRegistry: ctx.npmRegistry, }, - createPackageVersionProvider(lockfile), - ); - - Task.section('Installing plugin'); - await Task.forItem('moving', shortPluginDir, async () => { - await fs.move(tempDir, pluginDir).catch(error => { - throw new Error( - `Failed to move plugin from ${tempDir} to ${pluginDir}, ${error.message}`, - ); - }); }); - ctx.markAsModified(); - if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) { await Task.forItem('backend', 'adding dependency', async () => { await addPackageDependency( @@ -124,9 +85,9 @@ export const backendPlugin = createFactory({ } } - await Task.forCommand('yarn install', { cwd: pluginDir, optional: true }); + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); await Task.forCommand('yarn lint --fix', { - cwd: pluginDir, + cwd: targetDir, optional: true, }); }, 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..b644110a9f --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -0,0 +1,76 @@ +/* + * 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 { 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 = targetDir.replace(`${paths.targetRoot}/`, ''); + 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), + ); + + 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/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index 3fe01b7325..bb93bd311c 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -17,7 +17,6 @@ import fs from 'fs-extra'; import camelCase from 'lodash/camelCase'; import upperFirst from 'lodash/upperFirst'; -import chalk from 'chalk'; import { paths } from '../../paths'; import { addCodeownersEntry, @@ -25,10 +24,9 @@ import { parseOwnerIds, } from '../../codeowners'; import { createFactory, CreateContext } from '../types'; -import { Lockfile } from '../../versioning'; -import { addPackageDependency, Task, templatingTask } from '../../tasks'; -import { createPackageVersionProvider } from '../../version'; +import { addPackageDependency, Task } from '../../tasks'; import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; type Options = { id: string; @@ -49,60 +47,24 @@ export const frontendPlugin = createFactory({ const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; const extensionName = `${upperFirst(camelCase(id))}Page`; - const pluginDir = ctx.isMonoRepo + const targetDir = ctx.isMonoRepo ? paths.resolveTargetRoot('plugins', id) : paths.resolveTargetRoot(`backstage-plugin-${id}`); - let lockfile: Lockfile | undefined; - try { - lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); - } catch (error) { - console.warn(`No yarn.lock available, ${error}`); - } - - Task.section('Validating prerequisites'); - const shortPluginDir = pluginDir.replace(`${paths.targetRoot}/`, ''); - await Task.forItem('availability', shortPluginDir, async () => { - if (await fs.pathExists(pluginDir)) { - throw new Error( - `A plugin with the same 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-plugin-${id}`); - }); - - Task.section('Executing plugin template'); - await templatingTask( - paths.resolveOwn('templates/default-plugin'), - tempDir, - { + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-plugin', + values: { id, + name, + extensionName, pluginVar: `${camelCase(id)}Plugin`, pluginVersion: ctx.defaultVersion, - extensionName, - name, privatePackage: ctx.private, npmRegistry: ctx.npmRegistry, }, - createPackageVersionProvider(lockfile), - ); - - Task.section('Installing plugin'); - await Task.forItem('moving', shortPluginDir, async () => { - await fs.move(tempDir, pluginDir).catch(error => { - throw new Error( - `Failed to move plugin from ${tempDir} to ${pluginDir}, ${error.message}`, - ); - }); }); - ctx.markAsModified(); - if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) { await Task.forItem('app', 'adding dependency', async () => { await addPackageDependency( @@ -163,9 +125,9 @@ export const frontendPlugin = createFactory({ } } - await Task.forCommand('yarn install', { cwd: pluginDir, optional: true }); + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); await Task.forCommand('yarn lint --fix', { - cwd: pluginDir, + cwd: targetDir, optional: true, }); }, From 367e09e31fd828561f94024bf90355576774a295 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 16:45:56 +0100 Subject: [PATCH 043/118] cli: refactor to simplify codeowners logic Signed-off-by: Patrik Oldsberg --- .../commands/create-plugin/createPlugin.ts | 9 ++---- .../remove-plugin/removePlugin.test.ts | 4 +-- packages/cli/src/lib/codeowners/codeowners.ts | 30 ++++++++++++++----- .../src/lib/create/factories/backendPlugin.ts | 17 ++--------- .../lib/create/factories/frontendPlugin.ts | 17 ++--------- 5 files changed, 33 insertions(+), 44 deletions(-) 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/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 b3fac12109..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; } @@ -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/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts index 68c684f6ab..2fd461c2a9 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -17,11 +17,7 @@ import fs from 'fs-extra'; import camelCase from 'lodash/camelCase'; import { paths } from '../../paths'; -import { - addCodeownersEntry, - getCodeownersFilePath, - parseOwnerIds, -} from '../../codeowners'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; import { createFactory, CreateContext } from '../types'; import { addPackageDependency, Task } from '../../tasks'; import { ownerPrompt, pluginIdPrompt } from './common/prompts'; @@ -74,15 +70,8 @@ export const backendPlugin = createFactory({ }); } - if (options.codeOwnersPath && options.owner) { - const ownerIds = parseOwnerIds(options.owner); - if (ownerIds && ownerIds.length > 0) { - await addCodeownersEntry( - options.codeOwnersPath, - `/plugins/${id}`, - ownerIds, - ); - } + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); } await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index bb93bd311c..1d30d0235d 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -18,11 +18,7 @@ import fs from 'fs-extra'; import camelCase from 'lodash/camelCase'; import upperFirst from 'lodash/upperFirst'; import { paths } from '../../paths'; -import { - addCodeownersEntry, - getCodeownersFilePath, - parseOwnerIds, -} from '../../codeowners'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; import { createFactory, CreateContext } from '../types'; import { addPackageDependency, Task } from '../../tasks'; import { ownerPrompt, pluginIdPrompt } from './common/prompts'; @@ -114,15 +110,8 @@ export const frontendPlugin = createFactory({ }); } - if (options.codeOwnersPath && options.owner) { - const ownerIds = parseOwnerIds(options.owner); - if (ownerIds && ownerIds.length > 0) { - await addCodeownersEntry( - options.codeOwnersPath, - `/plugins/${id}`, - ownerIds, - ); - } + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); } await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); From 26eb174ce8f9ecd163738cc919aa8e27f51057c1 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Fri, 12 Nov 2021 15:33:22 +0000 Subject: [PATCH 044/118] Skip empty file names during scaffolder Signed-off-by: Tim Jacomb --- .changeset/ninety-spies-prove.md | 5 +++++ .../actions/builtin/fetch/template.test.ts | 17 +++++++++++++++++ .../actions/builtin/fetch/template.ts | 5 +++++ 3 files changed, 27 insertions(+) create mode 100644 .changeset/ninety-spies-prove.md 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/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( From 033f6abfcebc0b7ae1c276b412fcb306efe2443d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 18:15:03 +0100 Subject: [PATCH 045/118] cli: add tests for frontendPlugin create factory + minor fixes Signed-off-by: Patrik Oldsberg --- .../create/factories/frontendPlugin.test.ts | 232 ++++++++++++++++++ .../lib/create/factories/frontendPlugin.ts | 2 +- packages/cli/src/lib/tasks.ts | 8 +- 3 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/lib/create/factories/frontendPlugin.test.ts 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..fe815ce80f --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -0,0 +1,232 @@ +/* + * 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 { resolve as resolvePath } from 'path'; +import { WriteStream } from 'tty'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { frontendPlugin } from './frontendPlugin'; + +function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => + // Clean up colors and whitespace + // eslint-disable-next-line no-control-regex + output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), + } as unknown as WriteStream & { fd: any }, + ] as const; +} + +const appTsxContent = ` +import { createApp } from '@backstage/app-defaults'; + +const router = ( + + } /> + +) +`; + +describe('frontendPlugin factory', () => { + beforeEach(() => { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath('/root', ...ps)); + }); + + 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([ + 'Checking Prerequisites:', + 'availability plugins/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/test ✔', + 'app adding dependency ✔', + 'app adding import ✔', + ]); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + '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 'plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: '/root/plugins/test', + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: '/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: '/root/plugins/test', + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: '/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 index 1d30d0235d..50e8dd42a3 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -97,7 +97,7 @@ export const frontendPlugin = createFactory({ revLines.splice(lastImportIndex, 0, importLine); } - const componentLine = `}/>`; + const componentLine = `} />`; if (!content.includes(componentLine)) { const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 9e67f7f30c..c84eed435e 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -31,16 +31,16 @@ 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) { @@ -81,7 +81,7 @@ export class Task { } catch (error) { assertError(error); if (error.stderr) { - process.stdout.write(error.stderr as Buffer); + process.stderr.write(error.stderr as Buffer); } if (error.stdout) { process.stdout.write(error.stdout as Buffer); From 1cf00cbe334488b62f4c36f1f59170bea8ab0382 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 18:19:33 +0100 Subject: [PATCH 046/118] cli: added test for backendPlugin create factory Signed-off-by: Patrik Oldsberg --- .../create/factories/backendPlugin.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 packages/cli/src/lib/create/factories/backendPlugin.test.ts 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..1100b3001e --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { resolve as resolvePath } from 'path'; +import { WriteStream } from 'tty'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { backendPlugin } from './backendPlugin'; + +function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => + // Clean up colors and whitespace + // eslint-disable-next-line no-control-regex + output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), + } as unknown as WriteStream & { fd: any }, + ] as const; +} + +describe('backendPlugin factory', () => { + beforeEach(() => { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath('/root', ...ps)); + }); + + 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([ + 'Checking Prerequisites:', + 'availability plugins/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/test-backend ✔', + 'backend adding dependency ✔', + ]); + + await expect( + fs.readJson('/root/packages/backend/package.json'), + ).resolves.toEqual({ + dependencies: { + 'plugin-test-backend': '^1.0.0', + }, + }); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: '/root/plugins/test-backend', + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: '/root/plugins/test-backend', + optional: true, + }); + }); +}); From 8a60033962c0e529c81f12c1695225377c59242b Mon Sep 17 00:00:00 2001 From: Zach Falen Date: Fri, 12 Nov 2021 12:53:13 -0700 Subject: [PATCH 047/118] hotfix for Backstage token generation, prefer .token over .idToken Signed-off-by: Zach Falen --- .../src/layout/SignInPage/commonProvider.tsx | 4 +++- plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core-components/src/layout/SignInPage/commonProvider.tsx b/packages/core-components/src/layout/SignInPage/commonProvider.tsx index 103050a2d9..515ae01e4d 100644 --- a/packages/core-components/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/commonProvider.tsx @@ -49,7 +49,9 @@ const Component: ProviderComponent = ({ config, onResult }) => { 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/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; } } From 892c1d9202f42856e7585d670abb75f1d7059eaf Mon Sep 17 00:00:00 2001 From: Zach Falen Date: Fri, 12 Nov 2021 12:56:33 -0700 Subject: [PATCH 048/118] add changeset Signed-off-by: Zach Falen --- .changeset/ninety-grapes-love.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/ninety-grapes-love.md 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 From 7d6ab03ebbfc14ec4f1c8d0ebf9fa7957a200457 Mon Sep 17 00:00:00 2001 From: Zach Falen Date: Fri, 12 Nov 2021 13:32:48 -0700 Subject: [PATCH 049/118] update test Signed-off-by: Zach Falen --- plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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', }, }); }); From 46a161a023fcca6d2130f12fc88cbd7befff0a57 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 21:35:40 +0100 Subject: [PATCH 050/118] scripts: make snyk issue script executable Signed-off-by: Patrik Oldsberg --- scripts/snyk-github-issue-sync.ts | 1 + 1 file changed, 1 insertion(+) mode change 100644 => 100755 scripts/snyk-github-issue-sync.ts diff --git a/scripts/snyk-github-issue-sync.ts b/scripts/snyk-github-issue-sync.ts old mode 100644 new mode 100755 index f1015f8139..9120089266 --- a/scripts/snyk-github-issue-sync.ts +++ b/scripts/snyk-github-issue-sync.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env yarn ts-node --transpile-only /* * Copyright 2021 The Backstage Authors * From dd751588657de1ed2b787fa86a5c4aed37db14f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 21:39:26 +0100 Subject: [PATCH 051/118] scripts: tweak snyk script to match license issues Signed-off-by: Patrik Oldsberg --- scripts/snyk-github-issue-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snyk-github-issue-sync.ts b/scripts/snyk-github-issue-sync.ts index 9120089266..da64e2ea82 100755 --- a/scripts/snyk-github-issue-sync.ts +++ b/scripts/snyk-github-issue-sync.ts @@ -34,7 +34,7 @@ const argv = minimist(process.argv.slice(2)); const GH_OWNER = 'backstage'; const GH_REPO = 'backstage'; const SNYK_GH_LABEL = 'snyk-vulnerability'; -const SNYK_ID_REGEX = /\[([A-Z0-9-:]+)]/i; +const SNYK_ID_REGEX = /\[([^\]]+)]/i; const isDryRun = 'dryrun' in argv; From 8f2a7184386e1fdbb43b3d35d1bf90bbf681fdf4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 18:38:47 +0100 Subject: [PATCH 052/118] cli: added create factory and template for plugin common package Signed-off-by: Patrik Oldsberg --- .../cli/src/lib/create/factories/index.ts | 1 + .../lib/create/factories/pluginCommon.test.ts | 123 ++++++++++++++++++ .../src/lib/create/factories/pluginCommon.ts | 67 ++++++++++ .../.eslintrc.js | 3 + .../README.md.hbs | 5 + .../package.json.hbs | 32 +++++ .../src/index.ts | 16 +++ .../src/setupTests.ts | 1 + .../tsconfig.json | 9 ++ 9 files changed, 257 insertions(+) create mode 100644 packages/cli/src/lib/create/factories/pluginCommon.test.ts create mode 100644 packages/cli/src/lib/create/factories/pluginCommon.ts create mode 100644 packages/cli/templates/default-common-plugin-package/.eslintrc.js create mode 100644 packages/cli/templates/default-common-plugin-package/README.md.hbs create mode 100644 packages/cli/templates/default-common-plugin-package/package.json.hbs create mode 100644 packages/cli/templates/default-common-plugin-package/src/index.ts create mode 100644 packages/cli/templates/default-common-plugin-package/src/setupTests.ts create mode 100644 packages/cli/templates/default-common-plugin-package/tsconfig.json diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts index 2e16979e50..e4df9062f7 100644 --- a/packages/cli/src/lib/create/factories/index.ts +++ b/packages/cli/src/lib/create/factories/index.ts @@ -16,3 +16,4 @@ export { frontendPlugin } from './frontendPlugin'; export { backendPlugin } from './backendPlugin'; +export { pluginCommon } from './pluginCommon'; 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..f7251dc443 --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { resolve as resolvePath } from 'path'; +import { WriteStream } from 'tty'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { pluginCommon } from './pluginCommon'; + +function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => + // Clean up colors and whitespace + // eslint-disable-next-line no-control-regex + output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), + } as unknown as WriteStream & { fd: any }, + ] as const; +} + +describe('pluginCommon factory', () => { + beforeEach(() => { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath('/root', ...ps)); + }); + + 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([ + 'Checking Prerequisites:', + 'availability plugins/test-common ✔', + 'creating temp dir ✔', + 'Executing Template:', + 'copying .eslintrc.js ✔', + 'templating README.md.hbs ✔', + 'templating package.json.hbs ✔', + 'copying tsconfig.json ✔', + 'copying index.ts ✔', + 'copying setupTests.ts ✔', + 'Installing:', + 'moving plugins/test-common ✔', + ]); + + await expect( + fs.readJson('/root/plugins/test-common/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'plugin-test-common', + description: 'Common functionalities for the test-common plugin', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: '/root/plugins/test-common', + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: '/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..5a6dd30046 --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -0,0 +1,67 @@ +/* + * 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 { 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.id}-common`; + const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + 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/${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/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..c7ba25ac49 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -0,0 +1,32 @@ +{ + "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 b/packages/cli/templates/default-common-plugin-package/src/index.ts new file mode 100644 index 0000000000..6ee452b5bb --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts @@ -0,0 +1,16 @@ +/** + * Common functionalities for the {{id}} plugin. + */ + +/** + * 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": "." + } +} From 827fb840b46c59b4322d11e5400a2e6364aadcb8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 18:45:51 +0100 Subject: [PATCH 053/118] cli: fix for create crashing if no scope is specified Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 946918d77c..5fff07b194 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -86,7 +86,7 @@ export default async (cmd: Command) => { await factory.create(options, { isMonoRepo, defaultVersion, - scope: cmdOpts.scope.replace(/^@/, ''), + scope: cmdOpts.scope?.replace(/^@/, ''), npmRegistry: cmdOpts.npmRegistry, private: Boolean(cmdOpts.private), createTemporaryDirectory, From a9f8363b5d66bc55830ad9f90e1367c74efb5f15 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Nov 2021 18:46:25 +0100 Subject: [PATCH 054/118] cli: format package.json after creation to simplify template Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/common/tasks.ts | 8 ++ .../default-backend-plugin/package.json.hbs | 76 ++++++++++--------- .../package.json.hbs | 6 +- .../templates/default-plugin/package.json.hbs | 6 +- 4 files changed, 55 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts index b644110a9f..6fe2e77fc7 100644 --- a/packages/cli/src/lib/create/factories/common/tasks.ts +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import chalk from 'chalk'; +import { resolve as resolvePath } from 'path'; import { paths } from '../../../paths'; import { Task, templatingTask } from '../../../tasks'; import { Lockfile } from '../../../versioning'; @@ -63,6 +64,13 @@ export async function executePluginPackageTemplate( createPackageVersionProvider(lockfile), ); + // Format package.json if it exists + const targetPkgJsonPath = resolvePath(targetDir, 'package.json'); + if (await fs.pathExists(targetPkgJsonPath)) { + const pkgJson = await fs.readJson(targetPkgJsonPath); + await fs.writeJson(targetPkgJsonPath, pkgJson, { spaces: 2 }); + } + Task.section('Installing'); await Task.forItem('moving', shortPluginDir, async () => { await fs.move(tempDir, targetDir).catch(error => { 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/package.json.hbs b/packages/cli/templates/default-common-plugin-package/package.json.hbs index c7ba25ac49..efaee496e7 100644 --- a/packages/cli/templates/default-common-plugin-package/package.json.hbs +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -5,10 +5,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.cjs.js", 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", From 1c291cb66f2d61467b7a0eeb1c967dc5675456ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 10:32:25 +0100 Subject: [PATCH 055/118] package.json: add backstage-create script Signed-off-by: Patrik Oldsberg --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e6d376c46..a7c5e2fbf7 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 .", From 8c83ce8084df178556d60752bf3f0f0c50db4473 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 10:38:49 +0100 Subject: [PATCH 056/118] cli: leave initial create logging to the factories Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 3 --- packages/cli/src/lib/create/factories/backendPlugin.test.ts | 2 ++ packages/cli/src/lib/create/factories/backendPlugin.ts | 4 ++++ packages/cli/src/lib/create/factories/frontendPlugin.test.ts | 2 ++ packages/cli/src/lib/create/factories/frontendPlugin.ts | 4 ++++ packages/cli/src/lib/create/factories/pluginCommon.test.ts | 2 ++ packages/cli/src/lib/create/factories/pluginCommon.ts | 4 ++++ 7 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index 5fff07b194..d80daa6ddf 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -80,9 +80,6 @@ export default async (cmd: Command) => { let modified = false; try { - Task.log(); - Task.log(`Creating new ${factory.name}`); - await factory.create(options, { isMonoRepo, defaultVersion, diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts index 1100b3001e..cf597572ad 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -90,6 +90,8 @@ describe('backendPlugin factory', () => { expect(modified).toBe(true); expect(output).toEqual([ + '', + 'Creating backend plugin plugin-test-backend', 'Checking Prerequisites:', 'availability plugins/test-backend ✔', 'creating temp dir ✔', diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts index 2fd461c2a9..2bdc62741d 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; +import chalk from 'chalk'; import camelCase from 'lodash/camelCase'; import { paths } from '../../paths'; import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; @@ -40,6 +41,9 @@ export const backendPlugin = createFactory({ const id = `${options.id}-backend`; const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `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}`); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts index fe815ce80f..3b24f95e41 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -103,6 +103,8 @@ describe('frontendPlugin factory', () => { expect(modified).toBe(true); expect(output).toEqual([ + '', + 'Creating backend plugin plugin-test', 'Checking Prerequisites:', 'availability plugins/test ✔', 'creating temp dir ✔', diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index 50e8dd42a3..a6c0d0470d 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; +import chalk from 'chalk'; import camelCase from 'lodash/camelCase'; import upperFirst from 'lodash/upperFirst'; import { paths } from '../../paths'; @@ -43,6 +44,9 @@ export const frontendPlugin = createFactory({ const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `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}`); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index f7251dc443..6e797f4cca 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -85,6 +85,8 @@ describe('pluginCommon factory', () => { expect(modified).toBe(true); expect(output).toEqual([ + '', + 'Creating backend plugin plugin-test-common', 'Checking Prerequisites:', 'availability plugins/test-common ✔', 'creating temp dir ✔', diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts index 5a6dd30046..97b1eccec7 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import chalk from 'chalk'; import { paths } from '../../paths'; import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; import { createFactory, CreateContext } from '../types'; @@ -38,6 +39,9 @@ export const pluginCommon = createFactory({ const id = `${options.id}-common`; const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `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}`); From 7b8f19492edfea021cc4681c392e3e43f79d5b74 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 12:30:04 +0100 Subject: [PATCH 057/118] cli: add test for plugin package template execution + fix Signed-off-by: Patrik Oldsberg --- .../lib/create/factories/common/tasks.test.ts | 117 ++++++++++++++++++ .../src/lib/create/factories/common/tasks.ts | 8 +- .../lib/create/factories/common/testUtils.ts | 68 ++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/lib/create/factories/common/tasks.test.ts create mode 100644 packages/cli/src/lib/create/factories/common/testUtils.ts 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..b3a751ba5b --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -0,0 +1,117 @@ +/* + * 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 { 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 /target ✔', + 'creating temp dir ✔', + 'Executing Template:', + 'templating package.json.hbs ✔', + 'copying not-templated.txt ✔', + 'templating templated.txt.hbs ✔', + 'Installing:', + 'moving /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 index 6fe2e77fc7..fd3bd4d930 100644 --- a/packages/cli/src/lib/create/factories/common/tasks.ts +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -65,10 +65,10 @@ export async function executePluginPackageTemplate( ); // Format package.json if it exists - const targetPkgJsonPath = resolvePath(targetDir, 'package.json'); - if (await fs.pathExists(targetPkgJsonPath)) { - const pkgJson = await fs.readJson(targetPkgJsonPath); - await fs.writeJson(targetPkgJsonPath, pkgJson, { spaces: 2 }); + 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'); 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..651409451e --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -0,0 +1,68 @@ +/* + * 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 { 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) => + // Clean up colors and whitespace + // eslint-disable-next-line no-control-regex + output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), + } as unknown as WriteStream & { fd: any }, + ] as const; +} From 823a097b3b5a87205eefc8c086cf13e0ae0aa8c6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 12:36:47 +0100 Subject: [PATCH 058/118] cli: use common testing utils for create factory tests Signed-off-by: Patrik Oldsberg --- .../create/factories/backendPlugin.test.ts | 26 +++------------- .../create/factories/frontendPlugin.test.ts | 26 +++------------- .../lib/create/factories/pluginCommon.test.ts | 31 +++---------------- 3 files changed, 12 insertions(+), 71 deletions(-) diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts index cf597572ad..409b7f476a 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -16,35 +16,17 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; -import { WriteStream } from 'tty'; import { paths } from '../../paths'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; import { backendPlugin } from './backendPlugin'; -function createMockOutputStream() { - const output = new Array(); - return [ - output, - { - cursorTo: () => {}, - clearLine: () => {}, - moveCursor: () => {}, - write: (msg: string) => - // Clean up colors and whitespace - // eslint-disable-next-line no-control-regex - output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), - } as unknown as WriteStream & { fd: any }, - ] as const; -} - describe('backendPlugin factory', () => { beforeEach(() => { - jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...ps) => resolvePath('/root', ...ps)); + mockPaths({ + targetRoot: '/root', + }); }); afterEach(() => { diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts index 3b24f95e41..3e0f94f6ad 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -16,29 +16,12 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; -import { WriteStream } from 'tty'; import { paths } from '../../paths'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; import { frontendPlugin } from './frontendPlugin'; -function createMockOutputStream() { - const output = new Array(); - return [ - output, - { - cursorTo: () => {}, - clearLine: () => {}, - moveCursor: () => {}, - write: (msg: string) => - // Clean up colors and whitespace - // eslint-disable-next-line no-control-regex - output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), - } as unknown as WriteStream & { fd: any }, - ] as const; -} - const appTsxContent = ` import { createApp } from '@backstage/app-defaults'; @@ -51,10 +34,9 @@ const router = ( describe('frontendPlugin factory', () => { beforeEach(() => { - jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...ps) => resolvePath('/root', ...ps)); + mockPaths({ + targetRoot: '/root', + }); }); afterEach(() => { diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index 6e797f4cca..73edc5159b 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -16,40 +16,17 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; -import { WriteStream } from 'tty'; import { paths } from '../../paths'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; import { pluginCommon } from './pluginCommon'; -function createMockOutputStream() { - const output = new Array(); - return [ - output, - { - cursorTo: () => {}, - clearLine: () => {}, - moveCursor: () => {}, - write: (msg: string) => - // Clean up colors and whitespace - // eslint-disable-next-line no-control-regex - output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), - } as unknown as WriteStream & { fd: any }, - ] as const; -} - describe('pluginCommon factory', () => { beforeEach(() => { - jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue('/root'); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...ps) => resolvePath('/root', ...ps)); - }); - - afterEach(() => { - mockFs.restore(); - jest.resetAllMocks(); + mockPaths({ + targetRoot: '/root', + }); }); it('should create a common plugin package', async () => { From 9bbc2da04ee00711983284cf4955c05769623ca4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 13:18:07 +0100 Subject: [PATCH 059/118] docs/local-dev: add cli create command docs Signed-off-by: Patrik Oldsberg --- docs/local-dev/cli-commands.md | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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` From 16d06f6ac3ec8fd1553c4b847b96e692d9dc8125 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 13:20:48 +0100 Subject: [PATCH 060/118] changesets: add changeset for CLI create command Signed-off-by: Patrik Oldsberg --- .changeset/tidy-beans-reflect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-beans-reflect.md 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. From 6a46eb2693223fce4f08bb3b707397a0bd7c2a82 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 15:01:50 +0100 Subject: [PATCH 061/118] cli: refactor inquirer prompt message transforms for clarity Signed-off-by: Patrik Oldsberg --- .../cli/src/lib/create/FactoryRegistry.ts | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts index 1b25f5950a..9072d46c4c 100644 --- a/packages/cli/src/lib/create/FactoryRegistry.ts +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -16,10 +16,39 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; -import { AnyFactory } from './types'; +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]), @@ -82,19 +111,12 @@ export class FactoryRegistry { } currentOptions = await inquirer.prompt( - needsAnswers.map(option => ({ - ...option, - message: option.message && chalk.blue(option.message), - validate: - option.validate && - (async (...args) => { - const result = await option.validate!(...args); - if (typeof result === 'string') { - return chalk.red(result); - } - return result; - }), - })), + needsAnswers.map(option => + applyPromptMessageTransforms(option, { + message: chalk.blue, + error: chalk.red, + }), + ), currentOptions, ); } From 069934c62760d3bc00c1713ba183ee084c5386fc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 15:02:08 +0100 Subject: [PATCH 062/118] cli: some more docs for create types Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/create/types.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts index fd2e684e11..5a2460d0df 100644 --- a/packages/cli/src/lib/create/types.ts +++ b/packages/cli/src/lib/create/types.ts @@ -40,10 +40,31 @@ 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; } From bd3ae04de9e990ed9bad2c8051cb491721f18d94 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 15:26:23 +0100 Subject: [PATCH 063/118] cli: fixes for common package factory Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/pluginCommon.test.ts | 9 +++++++-- .../cli/src/lib/create/factories/pluginCommon.ts | 13 ++++++++----- .../src/{index.ts => index.ts.hbs} | 3 +++ 3 files changed, 18 insertions(+), 7 deletions(-) rename packages/cli/templates/default-common-plugin-package/src/{index.ts => index.ts.hbs} (90%) diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index 73edc5159b..e329e2588f 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -29,6 +29,11 @@ describe('pluginCommon factory', () => { }); }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + it('should create a common plugin package', async () => { mockFs({ '/root': { @@ -72,7 +77,7 @@ describe('pluginCommon factory', () => { 'templating README.md.hbs ✔', 'templating package.json.hbs ✔', 'copying tsconfig.json ✔', - 'copying index.ts ✔', + 'templating index.ts.hbs ✔', 'copying setupTests.ts ✔', 'Installing:', 'moving plugins/test-common ✔', @@ -83,7 +88,7 @@ describe('pluginCommon factory', () => { ).resolves.toEqual( expect.objectContaining({ name: 'plugin-test-common', - description: 'Common functionalities for the test-common plugin', + description: 'Common functionalities for the test plugin', private: true, version: '1.0.0', }), diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts index 97b1eccec7..af42d74b0b 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -36,15 +36,18 @@ export const pluginCommon = createFactory({ }), optionsPrompts: [pluginIdPrompt(), ownerPrompt()], async create(options: Options, ctx: CreateContext) { - const id = `${options.id}-common`; - const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + const { id } = options; + const suffix = `${id}-common`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${suffix}` + : `plugin-${suffix}`; Task.log(); Task.log(`Creating backend plugin ${chalk.cyan(name)}`); const targetDir = ctx.isMonoRepo - ? paths.resolveTargetRoot('plugins', id) - : paths.resolveTargetRoot(`backstage-plugin-${id}`); + ? paths.resolveTargetRoot('plugins', suffix) + : paths.resolveTargetRoot(`backstage-plugin-${suffix}`); await executePluginPackageTemplate(ctx, { targetDir, @@ -59,7 +62,7 @@ export const pluginCommon = createFactory({ }); if (options.owner) { - await addCodeownersEntry(`/plugins/${id}`, options.owner); + await addCodeownersEntry(`/plugins/${suffix}`, options.owner); } await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); diff --git a/packages/cli/templates/default-common-plugin-package/src/index.ts b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs similarity index 90% rename from packages/cli/templates/default-common-plugin-package/src/index.ts rename to packages/cli/templates/default-common-plugin-package/src/index.ts.hbs index 6ee452b5bb..2e1150d74e 100644 --- a/packages/cli/templates/default-common-plugin-package/src/index.ts +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs @@ -1,5 +1,8 @@ +/***/ /** * Common functionalities for the {{id}} plugin. + * + * @packageDocumentation */ /** From 103d9e2f53e97f93ef742715a88be15f9941bb3d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 15:29:33 +0100 Subject: [PATCH 064/118] cli: fix create monorepo check Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/create/create.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts index d80daa6ddf..64067e1b7e 100644 --- a/packages/cli/src/commands/create/create.ts +++ b/packages/cli/src/commands/create/create.ts @@ -51,10 +51,20 @@ export default async (cmd: Command) => { providedOptions, ); - const rootPackageJson = await fs.readJson( - paths.resolveTargetRoot('package.json'), - ); - const isMonoRepo = Boolean(rootPackageJson.workspaces); + 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 { From edaaf2dccb8a20f26a26c20b912bc90234350f74 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 15:32:20 +0100 Subject: [PATCH 065/118] cli: fix non-monorepo package naming for create Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/create/factories/backendPlugin.test.ts | 4 ++-- packages/cli/src/lib/create/factories/backendPlugin.ts | 4 +++- .../cli/src/lib/create/factories/frontendPlugin.test.ts | 6 +++--- packages/cli/src/lib/create/factories/frontendPlugin.ts | 4 +++- packages/cli/src/lib/create/factories/pluginCommon.test.ts | 4 ++-- packages/cli/src/lib/create/factories/pluginCommon.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts index 409b7f476a..5fe52c3732 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -73,7 +73,7 @@ describe('backendPlugin factory', () => { expect(output).toEqual([ '', - 'Creating backend plugin plugin-test-backend', + 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', 'availability plugins/test-backend ✔', 'creating temp dir ✔', @@ -97,7 +97,7 @@ describe('backendPlugin factory', () => { fs.readJson('/root/packages/backend/package.json'), ).resolves.toEqual({ dependencies: { - 'plugin-test-backend': '^1.0.0', + 'backstage-plugin-test-backend': '^1.0.0', }, }); diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts index 2bdc62741d..4d03a4da7b 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -39,7 +39,9 @@ export const backendPlugin = createFactory({ optionsPrompts: [pluginIdPrompt(), ownerPrompt()], async create(options: Options, ctx: CreateContext) { const id = `${options.id}-backend`; - const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; Task.log(); Task.log(`Creating backend plugin ${chalk.cyan(name)}`); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts index 3e0f94f6ad..a882a130ea 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -86,7 +86,7 @@ describe('frontendPlugin factory', () => { expect(output).toEqual([ '', - 'Creating backend plugin plugin-test', + 'Creating backend plugin backstage-plugin-test', 'Checking Prerequisites:', 'availability plugins/test ✔', 'creating temp dir ✔', @@ -117,14 +117,14 @@ describe('frontendPlugin factory', () => { fs.readJson('/root/packages/app/package.json'), ).resolves.toEqual({ dependencies: { - 'plugin-test': '^1.0.0', + '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 'plugin-test'; +import { TestPage } from 'backstage-plugin-test'; const router = ( diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts index a6c0d0470d..3862ae4883 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -41,7 +41,9 @@ export const frontendPlugin = createFactory({ async create(options: Options, ctx: CreateContext) { const { id } = options; - const name = ctx.scope ? `@${ctx.scope}/plugin-${id}` : `plugin-${id}`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; const extensionName = `${upperFirst(camelCase(id))}Page`; Task.log(); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index e329e2588f..a39bb3877d 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -68,7 +68,7 @@ describe('pluginCommon factory', () => { expect(output).toEqual([ '', - 'Creating backend plugin plugin-test-common', + 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', 'availability plugins/test-common ✔', 'creating temp dir ✔', @@ -87,7 +87,7 @@ describe('pluginCommon factory', () => { fs.readJson('/root/plugins/test-common/package.json'), ).resolves.toEqual( expect.objectContaining({ - name: 'plugin-test-common', + name: 'backstage-plugin-test-common', description: 'Common functionalities for the test plugin', private: true, version: '1.0.0', diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts index af42d74b0b..1bcca2fb6a 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.ts @@ -40,7 +40,7 @@ export const pluginCommon = createFactory({ const suffix = `${id}-common`; const name = ctx.scope ? `@${ctx.scope}/plugin-${suffix}` - : `plugin-${suffix}`; + : `backstage-plugin-${suffix}`; Task.log(); Task.log(`Creating backend plugin ${chalk.cyan(name)}`); From 6dcfe227a29006a43763bf2c1d5e345a4bfc0cb3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 16:32:20 +0100 Subject: [PATCH 066/118] cli: add create factory for scaffolder modules Signed-off-by: Patrik Oldsberg --- .changeset/shiny-starfishes-float.md | 5 + .../cli/src/lib/create/factories/index.ts | 1 + .../create/factories/scaffolderModule.test.ts | 110 ++++++++++++++++++ .../lib/create/factories/scaffolderModule.ts | 96 +++++++++++++++ packages/cli/src/lib/version.ts | 2 + .../templates/scaffolder-module/.eslintrc.js | 3 + .../templates/scaffolder-module/README.md.hbs | 5 + .../scaffolder-module/package.json.hbs | 37 ++++++ .../src/actions/example/example.test.ts | 50 ++++++++ .../src/actions/example/example.ts | 57 +++++++++ .../src/actions/example/index.ts | 1 + .../scaffolder-module/src/actions/index.ts | 1 + .../scaffolder-module/src/index.ts.hbs | 8 ++ .../templates/scaffolder-module/tsconfig.json | 9 ++ 14 files changed, 385 insertions(+) create mode 100644 .changeset/shiny-starfishes-float.md create mode 100644 packages/cli/src/lib/create/factories/scaffolderModule.test.ts create mode 100644 packages/cli/src/lib/create/factories/scaffolderModule.ts create mode 100644 packages/cli/templates/scaffolder-module/.eslintrc.js create mode 100644 packages/cli/templates/scaffolder-module/README.md.hbs create mode 100644 packages/cli/templates/scaffolder-module/package.json.hbs create mode 100644 packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts create mode 100644 packages/cli/templates/scaffolder-module/src/actions/example/example.ts create mode 100644 packages/cli/templates/scaffolder-module/src/actions/example/index.ts create mode 100644 packages/cli/templates/scaffolder-module/src/actions/index.ts create mode 100644 packages/cli/templates/scaffolder-module/src/index.ts.hbs create mode 100644 packages/cli/templates/scaffolder-module/tsconfig.json 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/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts index e4df9062f7..0764d33e2c 100644 --- a/packages/cli/src/lib/create/factories/index.ts +++ b/packages/cli/src/lib/create/factories/index.ts @@ -17,3 +17,4 @@ 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/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts new file mode 100644 index 0000000000..dde711b750 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { 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/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/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: '/root/plugins/scaffolder-backend-module-test', + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: '/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/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/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": "." + } +} From 4ca3542fdd0f14da90416ae19310757ce19bb8c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Nov 2021 17:38:38 +0100 Subject: [PATCH 067/118] cli: fix for backend bundle building all packages Signed-off-by: Patrik Oldsberg --- .changeset/sour-cameras-hide.md | 5 +++++ packages/cli/src/lib/packager/index.ts | 21 +++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 .changeset/sour-cameras-hide.md diff --git a/.changeset/sour-cameras-hide.md b/.changeset/sour-cameras-hide.md new file mode 100644 index 0000000000..a31e7a13c3 --- /dev/null +++ b/.changeset/sour-cameras-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead. diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index dba42e6013..4faf904177 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -106,18 +106,19 @@ export async function createDistWorkspace( if (options.buildDependencies) { const exclude = options.buildExcludes ?? []; - const scopeArgs = targets - .filter(target => !exclude.includes(target.name)) - .flatMap(target => ['--scope', target.name]); - const lernaArgs = - options.parallel && Number.isInteger(options.parallel) - ? ['--concurrency', options.parallel.toString()] - : []; + const toBuild = targets.filter(target => !exclude.includes(target.name)); + if (toBuild.length > 0) { + const scopeArgs = toBuild.flatMap(target => ['--scope', target.name]); + const lernaArgs = + options.parallel && Number.isInteger(options.parallel) + ? ['--concurrency', options.parallel.toString()] + : []; - await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { - cwd: paths.targetRoot, - }); + await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { + cwd: paths.targetRoot, + }); + } } await moveToDistWorkspace(targetDir, targets); From 59a3b58805569426bc114bac30d50713a98aa38b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 12 Nov 2021 17:25:24 +0100 Subject: [PATCH 068/118] fix(techdocs-cli): cross-platform build script Co-authored-by: Emma Indal Signed-off-by: Camila Belo --- package.json | 5 ++- packages/techdocs-cli/package.json | 1 - packages/techdocs-cli/scripts/build.sh | 45 ++++++++++--------- .../techdocs-cli/src/commands/serve/serve.ts | 4 +- packages/techdocs-cli/src/e2e.test.ts | 14 +++--- packages/techdocs-cli/src/lib/mkdocsServer.ts | 1 + scripts/techdocs-cli.js | 25 +++++++++++ 7 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 scripts/techdocs-cli.js diff --git a/package.json b/package.json index c0c68add37..d63790554f 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", - "techdocs-cli": "yarn workspace @techdocs/cli build && packages/techdocs-cli/bin/techdocs-cli", - "techdocs-cli:dev": "yarn workspace @techdocs/cli build:dev && TECHDOCS_CLI_DEV_MODE=true packages/techdocs-cli/bin/techdocs-cli", + "techdocs-cli": "node scripts/techdocs-cli.js", + "techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js", "prepare": "husky install", "lock:check": "yarn-lock-check" }, @@ -63,6 +63,7 @@ "@spotify/prettier-config": "^11.0.0", "@types/webpack": "^5.28.0", "command-exists": "^1.2.9", + "cross-env": "^7.0.0", "concurrently": "^6.0.0", "eslint-plugin-notice": "^0.9.10", "fs-extra": "9.1.0", diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 5367f0a151..a7ac3c3d80 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -22,7 +22,6 @@ "scripts": { "start": "nodemon --", "build": "./scripts/build.sh", - "build:dev": "TECHDOCS_CLI_DEV_MODE=true yarn build", "clean": "backstage-cli clean", "lint": "backstage-cli lint", "test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts", diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh index 7aec589b13..630fef42f0 100755 --- a/packages/techdocs-cli/scripts/build.sh +++ b/packages/techdocs-cli/scripts/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copyright 2020 The Backstage Authors +# 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. @@ -16,26 +16,29 @@ set -e -# Build the TechDocs CLI -npx backstage-cli -- build --outputs cjs +SCRIPT_DIR=$(dirname $0) +TECHDOCS_CLI_DIR="$SCRIPT_DIR"/.. +TECHDOCS_CLI_EMBEDDED_APP_DIR="$TECHDOCS_CLI_DIR"/../embedded-techdocs-app -# Make sure to do `yarn run build` in packages/embedded-techdocs before building here. +compile_and_build_cli() { + echo "📄 Compiling..." + yarn workspace @techdocs/cli tsc > /dev/null + echo "📦️ Building..." + pushd $TECHDOCS_CLI_DIR > /dev/null + npx backstage-cli build --outputs cjs > /dev/null + popd > /dev/null +} -EMBEDDED_TECHDOCS_APP_PATH=../embedded-techdocs-app -TECHDOCS_PREVIEW_SOURCE=$EMBEDDED_TECHDOCS_APP_PATH/dist -TECHDOCS_PREVIEW_DEST=dist/techdocs-preview-bundle +build_and_embed_app() { + echo "🚚 Embedding app..." + if [ "$TECHDOCS_CLI_DEV_MODE" = "true" ] ; then + yarn workspace embedded-techdocs-app build:dev > /dev/null + else + yarn workspace embedded-techdocs-app build > /dev/null + fi + cp -r "$TECHDOCS_CLI_EMBEDDED_APP_DIR"/dist "$TECHDOCS_CLI_DIR"/dist/techdocs-preview-bundle > /dev/null +} -# Build the embedded-techdocs-app -pushd $EMBEDDED_TECHDOCS_APP_PATH >/dev/null -if [[ $TECHDOCS_CLI_DEV_MODE == "true" ]]; then - yarn build:dev -else - yarn build -fi -popd >/dev/null - -cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST - -# Write to console -echo "[techdocs-cli]: Built the dist/ folder" -echo "[techdocs-cli]: Imported @backstage/plugin-techdocs dist/ folder into techdocs-preview-bundle/" +compile_and_build_cli +build_and_embed_app +echo "🏁 Ready!" diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 5aaf601c72..5fed2dd56e 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -77,8 +77,8 @@ export default async function serve(cmd: Command) { // Wait until mkdocs server has started so that Backstage starts with docs loaded // Takes 1-5 seconds - for (let attempt = 0; attempt < 10; attempt++) { - await new Promise(r => setTimeout(r, 1000)); + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 3000)); if (mkdocsServerHasStarted) { break; } diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts index 45fa112bde..9dbbf58431 100644 --- a/packages/techdocs-cli/src/e2e.test.ts +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -22,7 +22,7 @@ const FIXTURE_DIR = path.resolve(PROJECT_ROOT_DIR, 'src/fixture'); describe('end-to-end', () => { it('shows help text', async () => { - jest.setTimeout(10000); + jest.setTimeout(30000); const proc = await executeTechDocsCliCommand(['--help']); expect(proc.combinedStdOutErr).toContain('Usage: techdocs-cli [options]'); @@ -30,10 +30,10 @@ describe('end-to-end', () => { }); it('can generate', async () => { - jest.setTimeout(10000); + jest.setTimeout(30000); const proc = await executeTechDocsCliCommand(['generate', '--no-docker'], { cwd: FIXTURE_DIR, - killAfter: 8000, + killAfter: 16000, }); expect(proc.combinedStdOutErr).toContain('Successfully generated docs'); @@ -41,12 +41,12 @@ describe('end-to-end', () => { }); it('can serve in mkdocs', async () => { - jest.setTimeout(10000); + jest.setTimeout(30000); const proc = await executeTechDocsCliCommand( ['serve:mkdocs', '--no-docker'], { cwd: FIXTURE_DIR, - killAfter: 8000, + killAfter: 16000, }, ); @@ -55,10 +55,10 @@ describe('end-to-end', () => { }); it('can serve in backstage', async () => { - jest.setTimeout(10000); + jest.setTimeout(30000); const proc = await executeTechDocsCliCommand(['serve', '--no-docker'], { cwd: FIXTURE_DIR, - killAfter: 8000, + killAfter: 16000, }); expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts index 30e9b46451..60efcfaa89 100644 --- a/packages/techdocs-cli/src/lib/mkdocsServer.ts +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -40,6 +40,7 @@ export const runMkdocsServer = async (options: { `${process.cwd()}:/content`, '-p', `${port}:${port}`, + '-it', dockerImage, 'serve', '--dev-addr', diff --git a/scripts/techdocs-cli.js b/scripts/techdocs-cli.js new file mode 100644 index 0000000000..90dcc40f39 --- /dev/null +++ b/scripts/techdocs-cli.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +execSync(`yarn workspace @techdocs/cli build`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli link`, { stdio: 'ignore' }); +execSync(`techdocs-cli ${args.join(' ')}`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli unlink`, { stdio: 'ignore' }); From b5eec9e04410493ceb3548286ce1c1bc9230f532 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Nov 2021 04:13:26 +0000 Subject: [PATCH 069/118] build(deps-dev): bump eslint-plugin-cypress from 2.11.3 to 2.12.1 Bumps [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) from 2.11.3 to 2.12.1. - [Release notes](https://github.com/cypress-io/eslint-plugin-cypress/releases) - [Commits](https://github.com/cypress-io/eslint-plugin-cypress/compare/v2.11.3...v2.12.1) --- updated-dependencies: - dependency-name: eslint-plugin-cypress dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e18f6fedb2..7f5e8b6d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From 34a604b34ce70c3ac21fce7ec0174be3f9b061e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Nov 2021 04:15:32 +0000 Subject: [PATCH 070/118] build(deps): bump js-base64 from 3.6.1 to 3.7.2 Bumps [js-base64](https://github.com/dankogai/js-base64) from 3.6.1 to 3.7.2. - [Release notes](https://github.com/dankogai/js-base64/releases) - [Commits](https://github.com/dankogai/js-base64/compare/3.6.1...3.7.2) --- updated-dependencies: - dependency-name: js-base64 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e18f6fedb2..b18057da72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From 872a547dd6c3abeeb1eda41573fdee1023dc9e5f Mon Sep 17 00:00:00 2001 From: Terrence Benade Date: Mon, 15 Nov 2021 16:57:43 +1100 Subject: [PATCH 071/118] small grammatical error Signed-off-by: Terrence Benade --- docs/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b565119196897ad86fcf67d1899b96ce87514399 Mon Sep 17 00:00:00 2001 From: Dede Hamzah Date: Mon, 15 Nov 2021 14:19:28 +0700 Subject: [PATCH 072/118] update api report docs Signed-off-by: Dede Hamzah --- plugins/user-settings/api-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) // From 04fdd489f652c31c7de93415f774e2e7d2394493 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 15 Nov 2021 00:33:41 +0100 Subject: [PATCH 073/118] test(techdocs-cli): fix errors on windows Signed-off-by: Camila Belo --- packages/techdocs-cli/src/e2e.test.ts | 150 +++++++++++--------------- 1 file changed, 64 insertions(+), 86 deletions(-) diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts index 9dbbf58431..e6cf4e84c5 100644 --- a/packages/techdocs-cli/src/e2e.test.ts +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -14,115 +14,93 @@ * limitations under the License. */ -import { spawn } from 'child_process'; +import { execSync, spawn } from 'child_process'; import path from 'path'; -const PROJECT_ROOT_DIR = path.resolve(__dirname, '..'); -const FIXTURE_DIR = path.resolve(PROJECT_ROOT_DIR, 'src/fixture'); +const executeCommand = ( + command: string, + args: string[], + options?: Object, +): Promise<{ + exit: number; + stdout: string; + stderr: string; +}> => { + return new Promise(resolve => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const proc = + process.platform === 'win32' + ? spawn('cmd', ['/s', '/c', command, ...args], options) + : spawn(command, args, options); + + proc.stdout?.on('data', data => { + stdout.push(Buffer.from(data)); + }); + + proc.stderr?.on('data', data => { + stderr.push(Buffer.from(data)); + }); + + proc.on('exit', code => { + resolve({ + exit: code ?? 0, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +}; describe('end-to-end', () => { + const cwd = path.resolve(__dirname, 'fixture'); + + beforeAll(() => { + execSync('yarn workspace @techdocs/cli link', { stdio: 'ignore' }); + }); + + afterAll(() => { + execSync('yarn workspace @techdocs/cli unlink', { stdio: 'ignore' }); + }); + it('shows help text', async () => { jest.setTimeout(30000); - const proc = await executeTechDocsCliCommand(['--help']); - - expect(proc.combinedStdOutErr).toContain('Usage: techdocs-cli [options]'); + const proc = await executeCommand('techdocs-cli', ['--help']); + expect(proc.stdout).toContain('Usage: techdocs-cli [options]'); expect(proc.exit).toEqual(0); }); it('can generate', async () => { jest.setTimeout(30000); - const proc = await executeTechDocsCliCommand(['generate', '--no-docker'], { - cwd: FIXTURE_DIR, - killAfter: 16000, - }); - - expect(proc.combinedStdOutErr).toContain('Successfully generated docs'); + const proc = await executeCommand( + 'techdocs-cli', + ['generate', '--no-docker'], + { cwd, timeout: 25000 }, + ); + expect(proc.stdout).toContain('Successfully generated docs'); expect(proc.exit).toEqual(0); }); it('can serve in mkdocs', async () => { jest.setTimeout(30000); - const proc = await executeTechDocsCliCommand( + const proc = await executeCommand( + 'techdocs-cli', ['serve:mkdocs', '--no-docker'], - { - cwd: FIXTURE_DIR, - killAfter: 16000, - }, + { cwd, timeout: 25000 }, ); - - expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); + expect(proc.stdout).toContain('Starting mkdocs server'); expect(proc.exit).toEqual(0); }); it('can serve in backstage', async () => { jest.setTimeout(30000); - const proc = await executeTechDocsCliCommand(['serve', '--no-docker'], { - cwd: FIXTURE_DIR, - killAfter: 16000, - }); - - expect(proc.combinedStdOutErr).toContain('Starting mkdocs server'); - expect(proc.combinedStdOutErr).toContain('Serving docs in Backstage at'); + const proc = await executeCommand( + 'techdocs-cli', + ['serve', '--no-docker'], + { cwd, timeout: 25000 }, + ); + expect(proc.stdout).toContain('Starting mkdocs server'); + expect(proc.stdout).toContain('Serving docs in Backstage at'); expect(proc.exit).toEqual(0); }); }); - -type CommandResponse = { - stdout: string; - stderr: string; - combinedStdOutErr: string; - exit: number; -}; - -type ExecuteCommandOptions = { - killAfter?: number; - cwd?: string; -}; - -function executeTechDocsCliCommand( - args: string[], - opts: ExecuteCommandOptions = {}, -): Promise { - return new Promise(resolve => { - const pathToCli = path.resolve(PROJECT_ROOT_DIR, 'bin/techdocs-cli'); - const commandResponse = { - stdout: '', - stderr: '', - combinedStdOutErr: '', - exit: 0, - }; - - const listen = spawn(pathToCli, args, { - cwd: opts.cwd, - }); - - const stdOutChunks: any[] = []; - const stdErrChunks: any[] = []; - const combinedChunks: any[] = []; - - listen.stdout.on('data', data => { - stdOutChunks.push(data); - combinedChunks.push(data); - }); - - listen.stderr.on('data', data => { - stdErrChunks.push(data); - combinedChunks.push(data); - }); - - listen.on('exit', code => { - commandResponse.exit = code as number; - commandResponse.stdout = Buffer.concat(stdOutChunks).toString('utf8'); - commandResponse.stderr = Buffer.concat(stdErrChunks).toString('utf8'); - commandResponse.combinedStdOutErr = - Buffer.concat(combinedChunks).toString('utf8'); - resolve(commandResponse); - }); - - if (opts.killAfter) { - setTimeout(() => { - listen.kill('SIGTERM'); - }, opts.killAfter); - } - }); -} From 8809b6c0ddeb0d4cde5fc571786e4876abded8be Mon Sep 17 00:00:00 2001 From: Brian Fletcher Date: Mon, 15 Nov 2021 09:56:47 +0000 Subject: [PATCH 074/118] update versions of json-schema Signed-off-by: Brian Fletcher --- .changeset/empty-dots-attend.md | 8 ++++++++ packages/catalog-model/package.json | 2 +- packages/cli/package.json | 2 +- packages/config-loader/package.json | 2 +- plugins/scaffolder/package.json | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .changeset/empty-dots-attend.md 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/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..7253007b8f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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/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/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", From a08022254b3ea835df565431ec4dc4d5d46b8353 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Nov 2021 14:59:23 +0100 Subject: [PATCH 075/118] Add missing yarn.lock changes and reports Signed-off-by: Johan Haals --- plugins/catalog-backend/api-report.md | 1 + yarn.lock | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) 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/yarn.lock b/yarn.lock index 6898854595..37c1bd3329 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From 0b60a051c9512e7586579f1051a1233164300567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Can=20Bilgi=C3=A7?= Date: Mon, 15 Nov 2021 17:15:39 +0300 Subject: [PATCH 076/118] changeset name and type change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Bilgiç --- .changeset/chatty-months-report.md | 6 ------ .changeset/techdocs-chatty-months-report.md | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 .changeset/chatty-months-report.md create mode 100644 .changeset/techdocs-chatty-months-report.md diff --git a/.changeset/chatty-months-report.md b/.changeset/chatty-months-report.md deleted file mode 100644 index a0925be239..0000000000 --- a/.changeset/chatty-months-report.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/techdocs-common': minor -'@backstage/plugin-techdocs': minor ---- - -OpenStack Swift Migration Support added diff --git a/.changeset/techdocs-chatty-months-report.md b/.changeset/techdocs-chatty-months-report.md new file mode 100644 index 0000000000..fbc4faaf0c --- /dev/null +++ b/.changeset/techdocs-chatty-months-report.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs': patch +--- + +OpenStack Swift Migration Support added From b6a4bacdc4f35e1d5071f61224811213cb26a2af Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Nov 2021 16:14:48 +0100 Subject: [PATCH 077/118] core-plugin-api: added ErrorApi* prefix to Error and ErrorContext types Signed-off-by: Patrik Oldsberg --- .changeset/yellow-deers-act.md | 5 +++ packages/core-app-api/api-report.md | 7 ++-- packages/core-plugin-api/api-report.md | 32 +++++++++++-------- .../src/apis/definitions/ErrorApi.ts | 23 ++++++++++--- 4 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 .changeset/yellow-deers-act.md 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/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index d98b5ef5f8..26438ba798 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -28,8 +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 { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FeatureFlag } from '@backstage/core-plugin-api'; @@ -327,8 +328,8 @@ 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; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2765b02bca..87cbf1dd5f 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 = { 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; + }>; }; /** From 0b1de527326325aedbf0488c454a922cc03e8b01 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Nov 2021 16:21:02 +0100 Subject: [PATCH 078/118] core-app-api,test-utils: migrated to using new ErrorApi* names Signed-off-by: Patrik Oldsberg --- .changeset/nine-bananas-mate.md | 6 ++++++ docs/api/utility-apis.md | 2 +- packages/core-app-api/api-report.md | 9 ++++----- .../implementations/ErrorApi/ErrorAlerter.ts | 9 +++++++-- .../ErrorApi/ErrorApiForwarder.ts | 12 ++++++++---- .../ErrorApi/UnhandledErrorForwarder.ts | 10 +++++++--- packages/test-utils/api-report.md | 13 +++++++------ .../testUtils/apis/ErrorApi/MockErrorApi.ts | 19 +++++++++++++------ 8 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 .changeset/nine-bananas-mate.md 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/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/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 26438ba798..2c390bf132 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -31,7 +31,6 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; -import { ErrorContext } 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'; @@ -332,7 +331,7 @@ export class ErrorAlerter implements ErrorApi { context?: ErrorApiErrorContext | undefined; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -340,10 +339,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 @@ -604,7 +603,7 @@ export type SignInResult = { // @public export class UnhandledErrorForwarder { - static forward(errorApi: ErrorApi, errorContext: ErrorContext): void; + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; } // @public 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/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; } From 776c8924691e9d3b7412a87cfbfeff29483963fa Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 15 Nov 2021 16:28:08 +0100 Subject: [PATCH 079/118] Prepare package versions and changelog for release. Signed-off-by: Eric Peterson --- .changeset/mean-elephants-serve.md | 9 --------- packages/techdocs-cli/CHANGELOG.md | 26 ++++++++++++++++++++++++++ packages/techdocs-cli/package.json | 2 +- 3 files changed, 27 insertions(+), 10 deletions(-) delete mode 100644 .changeset/mean-elephants-serve.md diff --git a/.changeset/mean-elephants-serve.md b/.changeset/mean-elephants-serve.md deleted file mode 100644 index 2f048f0715..0000000000 --- a/.changeset/mean-elephants-serve.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@techdocs/cli': patch ---- - -Reunifies the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo -code back into the main [backstage](https://github.com/backstage/backstage) repo -(see [7288](https://github.com/backstage/backstage/issues/7288)). The changes -include some internal refactoring that do not affect functionality beyond the -local development setup. diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md index a8c6cd04fe..0f7c6be52f 100644 --- a/packages/techdocs-cli/CHANGELOG.md +++ b/packages/techdocs-cli/CHANGELOG.md @@ -1,5 +1,31 @@ # @techdocs/cli +## 0.8.5 + +### Patch Changes + +- Reunified the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo code back into the main [backstage](https://github.com/backstage/backstage) repo + + See [7288](https://github.com/backstage/backstage/issues/7288)). The changes include some internal refactoring that do not affect functionality beyond the local development setup. + +## 0.8.4 + +### Patch Changes + +- 8333394: The [change](https://github.com/backstage/techdocs-cli/commit/b25014cec313d46ce1c9b4f324cc09047a00fc1f) updated the `@backstage/techdocs-common` from version `0.9.0` to `0.10.2` and one of the intermediate versions, the [0.10.0](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#patch-changes-2), introduced the use of search in context that requires an implementation for the Search API. + + Created a custom techdocs page to disable search in the Reader component, preventing it from using the Search API, as we don't want to provide search in preview mode. + +## 0.8.3 + +### Patch Changes + +- edbb988: Upgrades the techdocs common page to the latest version 0.10.2. + + See [@backstage/techdocs-common changelog](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#L3). + +- db4ebfc: Add an `etag` flag to the `generate` command that is stored in the `techdocs_metadata.json` file. + ## 0.8.2 ### Patch Changes diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index a7ac3c3d80..0f18903282 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "Utility CLI for managing TechDocs sites in Backstage.", - "version": "0.8.4", + "version": "0.8.5", "private": false, "publishConfig": { "access": "public" From 7df99cdb77d795b7fd30482b5be4bdc45177f1a6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 10 Nov 2021 15:10:17 +0100 Subject: [PATCH 080/118] core-plugin-api: Remove exports of unused types Signed-off-by: Johan Haals --- .changeset/silent-taxis-tan.md | 5 +++++ packages/core-plugin-api/src/plugin/index.ts | 2 -- packages/core-plugin-api/src/plugin/types.ts | 17 ----------------- 3 files changed, 5 insertions(+), 19 deletions(-) create mode 100644 .changeset/silent-taxis-tan.md 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/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. * From 51d0fb3a02d73d4888b002501e895368b14af57a Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 15 Nov 2021 14:56:06 +0100 Subject: [PATCH 081/118] Fix API report Signed-off-by: Johan Haals --- packages/core-plugin-api/api-report.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2765b02bca..824b12d6fc 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -703,14 +703,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'; From b504cec62f31ec608891a9f4d12ef4c47ba75397 Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Mon, 15 Nov 2021 17:55:02 +0100 Subject: [PATCH 082/118] Small adjustments to the Header typography to use theme variables Signed-off-by: Philipp Hugenroth --- packages/core-components/src/layout/Header/Header.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 40495ca0e0..01baf50ad2 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], // '0 0 8px 3px rgba(20, 20, 20, 0.3)', 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: { From 7acc18e21a7911b668e201abc7dfe2acc29087f6 Mon Sep 17 00:00:00 2001 From: therynamo Date: Wed, 3 Nov 2021 14:38:57 -0500 Subject: [PATCH 083/118] feat: Allow SSE on AWS S3 Buckets Signed-off-by: therynamo --- .changeset/orange-cougars-relax.md | 5 +++++ docs/features/techdocs/configuration.md | 5 +++++ .../techdocs-common/src/stages/publish/awsS3.test.ts | 10 ++++++++++ packages/techdocs-common/src/stages/publish/awsS3.ts | 12 +++++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .changeset/orange-cougars-relax.md diff --git a/.changeset/orange-cougars-relax.md b/.changeset/orange-cougars-relax.md new file mode 100644 index 0000000000..2f55e23220 --- /dev/null +++ b/.changeset/orange-cougars-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Allow aws s3 buckets to pass an sse configuration so they can publish to encrypted buckets diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index fbe9f36540..cfc522adf6 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -106,6 +106,11 @@ 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 + # 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/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..05ce80eaca 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; @@ -75,6 +76,7 @@ export class AwsS3Publish implements PublisherBase { this.legacyPathCasing = options.legacyPathCasing; this.logger = options.logger; this.bucketRootPath = options.bucketRootPath; + this.sse = sse; } static fromConfig(config: Config, logger: Logger): PublisherBase { @@ -92,6 +94,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 +145,7 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, legacyPathCasing, logger, + sse, }); } @@ -208,6 +216,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 +259,8 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, ), Body: fileStream, - }; + ...(sse && { ServerSideEncryption: sse }), + } as aws.S3.PutObjectRequest; return this.storageClient.upload(params).promise(); }, From 16f7180fa2a34cb70925b941b9762e730b50266a Mon Sep 17 00:00:00 2001 From: Theryn Groetken Date: Thu, 4 Nov 2021 06:38:13 -0500 Subject: [PATCH 084/118] Update orange-cougars-relax.md Signed-off-by: therynamo --- .changeset/orange-cougars-relax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/orange-cougars-relax.md b/.changeset/orange-cougars-relax.md index 2f55e23220..0a13987551 100644 --- a/.changeset/orange-cougars-relax.md +++ b/.changeset/orange-cougars-relax.md @@ -2,4 +2,4 @@ '@backstage/techdocs-common': patch --- -Allow aws s3 buckets to pass an sse configuration so they can publish to encrypted buckets +Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets From 9e64a7ac1e0823fdef7304ce06d9e18960f8dfa7 Mon Sep 17 00:00:00 2001 From: therynamo Date: Mon, 15 Nov 2021 10:51:36 -0600 Subject: [PATCH 085/118] pr suggestions Signed-off-by: therynamo --- ...-cougars-relax.md => techdocs-orange-cougars-relax.md} | 1 + docs/features/techdocs/configuration.md | 1 + plugins/techdocs-backend/config.d.ts | 8 ++++++++ 3 files changed, 10 insertions(+) rename .changeset/{orange-cougars-relax.md => techdocs-orange-cougars-relax.md} (82%) diff --git a/.changeset/orange-cougars-relax.md b/.changeset/techdocs-orange-cougars-relax.md similarity index 82% rename from .changeset/orange-cougars-relax.md rename to .changeset/techdocs-orange-cougars-relax.md index 0a13987551..3776c9ea15 100644 --- a/.changeset/orange-cougars-relax.md +++ b/.changeset/techdocs-orange-cougars-relax.md @@ -1,5 +1,6 @@ --- '@backstage/techdocs-common': patch +'@backstage/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/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index cfc522adf6..61d95f55c0 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -107,6 +107,7 @@ techdocs: 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 diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 0023a4b757..d8049daec9 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'; }; } | { From dee1db5f8ac526a58fc66c56cdd4da2654389839 Mon Sep 17 00:00:00 2001 From: Soren Mathiasen Date: Mon, 15 Nov 2021 18:30:55 +0100 Subject: [PATCH 086/118] Adding Tradeshift as adopters Signed-off-by: Soren Mathiasen --- ADOPTERS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 6736538fa0..f5f5e260c9 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -64,4 +64,5 @@ | [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 | From 2345b318fb49fb79c19e4ba087bffca279b3fd55 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Nov 2021 22:36:41 +0100 Subject: [PATCH 087/118] create-app: fix windows file copy and mocking Signed-off-by: Patrik Oldsberg --- packages/create-app/src/createApp.test.ts | 2 +- packages/create-app/src/lib/tasks.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts index 7b3b8f124c..09099e2d15 100644 --- a/packages/create-app/src/createApp.test.ts +++ b/packages/create-app/src/createApp.test.ts @@ -25,7 +25,7 @@ jest.mock('./lib/tasks'); beforeAll(() => { mockFs({ - 'package.json': '', // required by `findPaths(__dirname)` + [`${__dirname}/package.json`]: '', // required by `findPaths(__dirname)` 'templates/': mockFs.load(path.resolve(__dirname, '../templates/')), }); }); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 0b3ca63728..3961e84753 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -19,7 +19,12 @@ import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; import recursive from 'recursive-readdir'; -import { basename, dirname, resolve as resolvePath } from 'path'; +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'; @@ -85,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')) { From ad3afc5ad63ced76ab81d2c07607023ad5a9d705 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 00:22:37 +0100 Subject: [PATCH 088/118] cli: trim away non-ascii in stream mock testing util Signed-off-by: Patrik Oldsberg --- .../create/factories/backendPlugin.test.ts | 28 ++++++------- .../lib/create/factories/common/tasks.test.ts | 12 +++--- .../lib/create/factories/common/testUtils.ts | 15 +++++-- .../create/factories/frontendPlugin.test.ts | 42 +++++++++---------- .../lib/create/factories/pluginCommon.test.ts | 18 ++++---- .../create/factories/scaffolderModule.test.ts | 24 +++++------ 6 files changed, 73 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts index 5fe52c3732..916ba7187c 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -75,22 +75,22 @@ describe('backendPlugin factory', () => { '', 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', - 'availability plugins/test-backend ✔', - 'creating temp dir ✔', + 'availability plugins/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 ✔', + '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/test-backend ✔', - 'backend adding dependency ✔', + 'moving plugins/test-backend', + 'backend adding dependency', ]); await expect( diff --git a/packages/cli/src/lib/create/factories/common/tasks.test.ts b/packages/cli/src/lib/create/factories/common/tasks.test.ts index b3a751ba5b..0892322e61 100644 --- a/packages/cli/src/lib/create/factories/common/tasks.test.ts +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -88,14 +88,14 @@ some-package@^1.1.0: expect(modified).toBe(true); expect(output).toEqual([ 'Checking Prerequisites:', - 'availability /target ✔', - 'creating temp dir ✔', + 'availability /target', + 'creating temp dir', 'Executing Template:', - 'templating package.json.hbs ✔', - 'copying not-templated.txt ✔', - 'templating templated.txt.hbs ✔', + 'templating package.json.hbs', + 'copying not-templated.txt', + 'templating templated.txt.hbs', 'Installing:', - 'moving /target ✔', + 'moving /target', ]); await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ "name": "my-testing-plugin", diff --git a/packages/cli/src/lib/create/factories/common/testUtils.ts b/packages/cli/src/lib/create/factories/common/testUtils.ts index 651409451e..01081a7486 100644 --- a/packages/cli/src/lib/create/factories/common/testUtils.ts +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +/* eslint-disable no-control-regex */ + import { WriteStream } from 'tty'; import { resolve as resolvePath } from 'path'; import { paths } from '../../../paths'; @@ -59,10 +61,15 @@ export function createMockOutputStream() { cursorTo: () => {}, clearLine: () => {}, moveCursor: () => {}, - write: (msg: string) => - // Clean up colors and whitespace - // eslint-disable-next-line no-control-regex - output.push(msg.replace(/\x1B\[\d\dm/g, '').trim()), + 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 index a882a130ea..439b134478 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -88,29 +88,29 @@ describe('frontendPlugin factory', () => { '', 'Creating backend plugin backstage-plugin-test', 'Checking Prerequisites:', - 'availability plugins/test ✔', - 'creating temp dir ✔', + 'availability plugins/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 ✔', + '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/test ✔', - 'app adding dependency ✔', - 'app adding import ✔', + 'moving plugins/test', + 'app adding dependency', + 'app adding import', ]); await expect( diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index a39bb3877d..3124df6b1d 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -70,17 +70,17 @@ describe('pluginCommon factory', () => { '', 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', - 'availability plugins/test-common ✔', - 'creating temp dir ✔', + 'availability plugins/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 ✔', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying setupTests.ts', 'Installing:', - 'moving plugins/test-common ✔', + 'moving plugins/test-common', ]); await expect( diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts index dde711b750..a5f1f8e63a 100644 --- a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -70,20 +70,20 @@ describe('scaffolderModule factory', () => { '', 'Creating module backstage-plugin-scaffolder-backend-module-test', 'Checking Prerequisites:', - 'availability plugins/scaffolder-backend-module-test ✔', - 'creating temp dir ✔', + 'availability plugins/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 ✔', + '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/scaffolder-backend-module-test ✔', + 'moving plugins/scaffolder-backend-module-test', ]); await expect( From 6f279f9b8eebf4cea506baea2c6bca733de290c6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 01:52:56 +0100 Subject: [PATCH 089/118] cli: fix failing create tests on windows Signed-off-by: Patrik Oldsberg --- .../src/lib/create/factories/backendPlugin.test.ts | 9 +++++---- .../src/lib/create/factories/common/tasks.test.ts | 5 +++-- .../cli/src/lib/create/factories/common/tasks.ts | 4 ++-- .../src/lib/create/factories/frontendPlugin.test.ts | 13 +++++++------ .../src/lib/create/factories/pluginCommon.test.ts | 9 +++++---- .../lib/create/factories/scaffolderModule.test.ts | 9 +++++---- 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts index 916ba7187c..6f4a73c02d 100644 --- a/packages/cli/src/lib/create/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -16,6 +16,7 @@ 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'; @@ -75,7 +76,7 @@ describe('backendPlugin factory', () => { '', 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', - 'availability plugins/test-backend', + `availability plugins${sep}test-backend`, 'creating temp dir', 'Executing Template:', 'copying .eslintrc.js', @@ -89,7 +90,7 @@ describe('backendPlugin factory', () => { 'copying router.ts', 'templating standaloneServer.ts.hbs', 'Installing:', - 'moving plugins/test-backend', + `moving plugins${sep}test-backend`, 'backend adding dependency', ]); @@ -103,11 +104,11 @@ describe('backendPlugin factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: '/root/plugins/test-backend', + cwd: resolvePath('/root/plugins/test-backend'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: '/root/plugins/test-backend', + cwd: resolvePath('/root/plugins/test-backend'), optional: 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 index 0892322e61..49381676e7 100644 --- a/packages/cli/src/lib/create/factories/common/tasks.test.ts +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -16,6 +16,7 @@ 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'; @@ -88,14 +89,14 @@ some-package@^1.1.0: expect(modified).toBe(true); expect(output).toEqual([ 'Checking Prerequisites:', - 'availability /target', + `availability ..${sep}target`, 'creating temp dir', 'Executing Template:', 'templating package.json.hbs', 'copying not-templated.txt', 'templating templated.txt.hbs', 'Installing:', - 'moving /target', + `moving ..${sep}target`, ]); await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ "name": "my-testing-plugin", diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts index fd3bd4d930..93af1e2b00 100644 --- a/packages/cli/src/lib/create/factories/common/tasks.ts +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import chalk from 'chalk'; -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, relative as relativePath } from 'path'; import { paths } from '../../../paths'; import { Task, templatingTask } from '../../../tasks'; import { Lockfile } from '../../../versioning'; @@ -41,7 +41,7 @@ export async function executePluginPackageTemplate( } Task.section('Checking Prerequisites'); - const shortPluginDir = targetDir.replace(`${paths.targetRoot}/`, ''); + const shortPluginDir = relativePath(paths.targetRoot, targetDir); await Task.forItem('availability', shortPluginDir, async () => { if (await fs.pathExists(targetDir)) { throw new Error( diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts index 439b134478..27ee14ea2c 100644 --- a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -16,6 +16,7 @@ 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'; @@ -88,7 +89,7 @@ describe('frontendPlugin factory', () => { '', 'Creating backend plugin backstage-plugin-test', 'Checking Prerequisites:', - 'availability plugins/test', + `availability plugins${sep}test`, 'creating temp dir', 'Executing Template:', 'copying .eslintrc.js', @@ -108,7 +109,7 @@ describe('frontendPlugin factory', () => { 'templating ExampleFetchComponent.tsx.hbs', 'copying index.ts', 'Installing:', - 'moving plugins/test', + `moving plugins${sep}test`, 'app adding dependency', 'app adding import', ]); @@ -136,11 +137,11 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: '/root/plugins/test', + cwd: resolvePath('/root/plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: '/root/plugins/test', + cwd: resolvePath('/root/plugins/test'), optional: true, }); }); @@ -205,11 +206,11 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: '/root/plugins/test', + cwd: resolvePath('/root/plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: '/root/plugins/test', + cwd: resolvePath('/root/plugins/test'), optional: true, }); }); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts index 3124df6b1d..602fce7bbd 100644 --- a/packages/cli/src/lib/create/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -16,6 +16,7 @@ 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'; @@ -70,7 +71,7 @@ describe('pluginCommon factory', () => { '', 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', - 'availability plugins/test-common', + `availability plugins${sep}test-common`, 'creating temp dir', 'Executing Template:', 'copying .eslintrc.js', @@ -80,7 +81,7 @@ describe('pluginCommon factory', () => { 'templating index.ts.hbs', 'copying setupTests.ts', 'Installing:', - 'moving plugins/test-common', + `moving plugins${sep}test-common`, ]); await expect( @@ -96,11 +97,11 @@ describe('pluginCommon factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: '/root/plugins/test-common', + cwd: resolvePath('/root/plugins/test-common'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: '/root/plugins/test-common', + cwd: resolvePath('/root/plugins/test-common'), optional: true, }); }); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts index a5f1f8e63a..c60c58e619 100644 --- a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -16,6 +16,7 @@ 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'; @@ -70,7 +71,7 @@ describe('scaffolderModule factory', () => { '', 'Creating module backstage-plugin-scaffolder-backend-module-test', 'Checking Prerequisites:', - 'availability plugins/scaffolder-backend-module-test', + `availability plugins${sep}scaffolder-backend-module-test`, 'creating temp dir', 'Executing Template:', 'copying .eslintrc.js', @@ -83,7 +84,7 @@ describe('scaffolderModule factory', () => { 'copying example.ts', 'copying index.ts', 'Installing:', - 'moving plugins/scaffolder-backend-module-test', + `moving plugins${sep}scaffolder-backend-module-test`, ]); await expect( @@ -99,11 +100,11 @@ describe('scaffolderModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: '/root/plugins/scaffolder-backend-module-test', + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: '/root/plugins/scaffolder-backend-module-test', + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), optional: true, }); }); From 6c1348591e513137442f6d539fae3d905f72e442 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Nov 2021 04:22:02 +0000 Subject: [PATCH 090/118] build(deps-dev): bump prettier from 2.4.0 to 2.4.1 Bumps [prettier](https://github.com/prettier/prettier) from 2.4.0 to 2.4.1. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/2.4.0...2.4.1) --- updated-dependencies: - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 37c1bd3329..9bff375969 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23142,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" From c1858c4cf95031c9ec11fa9daa87b70f94250a68 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 16 Nov 2021 10:25:01 +0100 Subject: [PATCH 091/118] Break apart changesets and clear yarn.lock diff Signed-off-by: Eric Peterson --- .changeset/techdocs-chatt-months-report-too.md | 5 +++++ .changeset/techdocs-chatty-months-report.md | 3 +-- yarn.lock | 10 +++++----- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 .changeset/techdocs-chatt-months-report-too.md 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 index fbc4faaf0c..7d3fa773bf 100644 --- a/.changeset/techdocs-chatty-months-report.md +++ b/.changeset/techdocs-chatty-months-report.md @@ -1,6 +1,5 @@ --- '@backstage/techdocs-common': patch -'@backstage/plugin-techdocs': patch --- -OpenStack Swift Migration Support added +Added OpenStack Swift case migration support. diff --git a/yarn.lock b/yarn.lock index 591ee92793..adf51ccb32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -29497,4 +29497,4 @@ zwitch@^1.0.0: zwitch@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" - integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== \ No newline at end of file + integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== From b0c7748ab5cc2d124dd03dd87155d19c588f128f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 16 Nov 2021 10:34:56 +0100 Subject: [PATCH 092/118] Ensure sse config is optional; pass through sse from constructor. Signed-off-by: Eric Peterson --- packages/techdocs-common/src/stages/publish/awsS3.ts | 3 ++- plugins/techdocs-backend/config.d.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 05ce80eaca..abfa8ddd24 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -70,13 +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 = sse; + this.sse = options.sse; } static fromConfig(config: Config, logger: Logger): PublisherBase { diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index d8049daec9..da3cd34830 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -128,7 +128,7 @@ export interface Config { * 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'; + sse?: 'aws:kms' | 'AES256'; }; } | { From fe20e6f4a8fa3bb990ca186188a28e70283a1105 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 16 Nov 2021 17:18:40 +0800 Subject: [PATCH 093/118] Update Kafka configuration types According to https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options the `ssl` options `ca`, `key`, and `cert` are all optional. In my case, i need to pass `rejectUnauthorized`, but I can't do so as the backend expects a `key`. Passing an empty string works, but everything becomes redacted in my log because of that. Signed-off-by: Gauthier Roebroeck --- plugins/kafka-backend/config.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/kafka-backend/config.d.ts b/plugins/kafka-backend/config.d.ts index ee29560809..1417c1fe63 100644 --- a/plugins/kafka-backend/config.d.ts +++ b/plugins/kafka-backend/config.d.ts @@ -33,8 +33,9 @@ export interface Config { | { ca: string[]; /** @visibility secret */ - key: string; + key?: string; cert: string; + rejectUnauthorized?: boolean; } | boolean; /** From f4fe544f73230f142fcaf84531f49b86f77b3e58 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 16 Nov 2021 17:45:35 +0800 Subject: [PATCH 094/118] Update config.d.ts Signed-off-by: Gauthier Roebroeck --- plugins/kafka-backend/config.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/kafka-backend/config.d.ts b/plugins/kafka-backend/config.d.ts index 1417c1fe63..3e7c196bd7 100644 --- a/plugins/kafka-backend/config.d.ts +++ b/plugins/kafka-backend/config.d.ts @@ -31,10 +31,10 @@ export interface Config { */ ssl?: | { - ca: string[]; + ca?: string[]; /** @visibility secret */ key?: string; - cert: string; + cert?: string; rejectUnauthorized?: boolean; } | boolean; From ecd1fcb80a8a0b05a78507cee9f76e8ef076c1a5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 10:57:05 +0100 Subject: [PATCH 095/118] core-app-api: deprecate the BackstagePluginWithAnyOutput type Signed-off-by: Patrik Oldsberg --- .changeset/rare-lemons-boil.md | 5 +++++ packages/core-app-api/api-report.md | 11 +++++++++-- packages/core-app-api/src/app/types.ts | 5 ++++- 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .changeset/rare-lemons-boil.md 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/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 2c390bf132..788127922f 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -197,7 +197,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; @@ -269,7 +276,7 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public +// @public @deprecated export type BackstagePluginWithAnyOutput = Omit< BackstagePlugin, 'output' 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. From 26b5da1c1aadabe65c741c0d6ee0a2610f9acae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 16 Nov 2021 11:42:10 +0100 Subject: [PATCH 096/118] Do not redact the empty string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/spicy-rice-build.md | 5 +++++ packages/backend-common/src/logging/rootLogger.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .changeset/spicy-rice-build.md diff --git a/.changeset/spicy-rice-build.md b/.changeset/spicy-rice-build.md new file mode 100644 index 0000000000..d8da66f66c --- /dev/null +++ b/.changeset/spicy-rice-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Do not redact the empty string, destroying all logs diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index b05763a42c..2a6e92ac87 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,14 @@ export function setRootLogger(newLogger: winston.Logger) { } export function setRootLoggerRedactionList(redactionList: string[]) { - if (redactionList.length) { + const filtered = redactionList.filter(Boolean); + if (filtered.length) { redactionRegExp = new RegExp( - `(${redactionList.map(escapeRegExp).join('|')})`, + `(${filtered.map(escapeRegExp).join('|')})`, 'g', ); + } else { + redactionRegExp = undefined; } } From f9daf056e52cf9632b250b87a08f1493eaa8dab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 16 Nov 2021 11:47:51 +0100 Subject: [PATCH 097/118] add test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../backend-common/src/logging/rootLogger.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 192decb653..67353a2f2d 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 secrets', () => { + const logger = createRootLogger(); + jest.spyOn(logger, 'write'); + setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', '']); + logger.info('Logging SECRET-1 and SECRET_2'); + + expect(logger.write).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Logging [REDACTED] and [REDACTED]', + }), + ); + }); + describe('createRootLogger', () => { it('creates a new logger', () => { const oldLogger = getRootLogger(); From f7703981a96b539f9f04b74362baf9805a2db15c Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Tue, 16 Nov 2021 11:35:30 +0100 Subject: [PATCH 098/118] Use a better checkbox rendering in a task list Signed-off-by: Dominik Henneke --- .changeset/techdocs-sharp-knives-unite.md | 5 +++++ .../techdocs/src/reader/components/Reader.tsx | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .changeset/techdocs-sharp-knives-unite.md 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/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, ], From 9145449220cded4a633fb339d0d9dfec880eea12 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 16 Nov 2021 19:11:46 +0800 Subject: [PATCH 099/118] Create fresh-zebras-hug.md Signed-off-by: Gauthier Roebroeck --- .changeset/fresh-zebras-hug.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fresh-zebras-hug.md diff --git a/.changeset/fresh-zebras-hug.md b/.changeset/fresh-zebras-hug.md new file mode 100644 index 0000000000..b18c6acdfc --- /dev/null +++ b/.changeset/fresh-zebras-hug.md @@ -0,0 +1,6 @@ +--- +"@backstage/plugin-kafka-backend": patch +"@backstage/plugin-kafka": patch +--- + +Update Kafka configuration types From 7b3bb89e3c269172f4d6d68d91b90b83a8aaa7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20MORI?= Date: Mon, 15 Nov 2021 16:47:05 +0100 Subject: [PATCH 100/118] Set doc reader parametable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane MORI --- .changeset/silver-plums-speak.md | 5 +++++ .../techdocs/src/reader/components/Reader.tsx | 20 +++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 .changeset/silver-plums-speak.md diff --git a/.changeset/silver-plums-speak.md b/.changeset/silver-plums-speak.md new file mode 100644 index 0000000000..2277b7bf12 --- /dev/null +++ b/.changeset/silver-plums-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 4e12ac18d1..8a39de45d4 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -76,8 +76,12 @@ const TechDocsReaderContext = createContext( {} as TechDocsReaderValue, ); -const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { - const { namespace = '', kind = '', name = '', '*': path } = useParams(); +const TechDocsReaderProvider = ({ + children, + entityRef, +}: PropsWithChildren<{ entityRef: EntityName }>) => { + const { '*': path } = useParams(); + const { kind, namespace, name } = entityRef; const value = useReaderState(kind, namespace, name, path); return ( @@ -96,10 +100,10 @@ const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { * @internal */ export const withTechDocsReaderProvider = - (Component: ComponentType) => + (Component: ComponentType, entityRef: EntityName) => (props: T) => ( - + ); @@ -128,12 +132,12 @@ export const useTechDocsReader = () => useContext(TechDocsReaderContext); * todo: Make public or stop exporting (see others: "altReaderExperiments") * @internal */ -export const useTechDocsReaderDom = (): Element | null => { +export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { const navigate = useNavigate(); const theme = useTheme(); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const { namespace = '', kind = '', name = '' } = useParams(); + const { namespace = '', kind = '', name = '' } = entityRef; const { state, path, content: rawPage } = useTechDocsReader(); const [sidebars, setSidebars] = useState(); @@ -400,7 +404,7 @@ const TheReader = ({ withSearch = true, }: Props) => { const classes = useStyles(); - const dom = useTechDocsReaderDom(); + const dom = useTechDocsReaderDom(entityRef); const shadowDomRef = useRef(null); const onReadyRef = useRef<() => void>(onReady); @@ -440,7 +444,7 @@ export const Reader = ({ onReady = () => {}, withSearch = true, }: Props) => ( - + Date: Tue, 16 Nov 2021 13:29:58 +0100 Subject: [PATCH 101/118] Add changeset Signed-off-by: Philipp Hugenroth --- .changeset/little-numbers-thank.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-numbers-thank.md 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. From 50154fb792222551a0530846505fd10d810f04d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 16 Nov 2021 13:37:28 +0100 Subject: [PATCH 102/118] exclude one-character secrets as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/spicy-rice-build.md | 2 +- packages/backend-common/src/logging/rootLogger.test.ts | 8 ++++---- packages/backend-common/src/logging/rootLogger.ts | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/spicy-rice-build.md b/.changeset/spicy-rice-build.md index d8da66f66c..c0322d778a 100644 --- a/.changeset/spicy-rice-build.md +++ b/.changeset/spicy-rice-build.md @@ -2,4 +2,4 @@ '@backstage/backend-common': patch --- -Do not redact the empty string, destroying all logs +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/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 67353a2f2d..0a664f9656 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -48,15 +48,15 @@ describe('rootLogger', () => { ); }); - it('redacts but ignores empty secrets', () => { + it('redacts but ignores empty and one-character secrets', () => { const logger = createRootLogger(); jest.spyOn(logger, 'write'); - setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', '']); - logger.info('Logging SECRET-1 and SECRET_2'); + 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]', + message: 'Logging [REDACTED] and [REDACTED] and Q', }), ); }); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 2a6e92ac87..2a4226f88f 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -34,7 +34,11 @@ export function setRootLogger(newLogger: winston.Logger) { } export function setRootLoggerRedactionList(redactionList: string[]) { - const filtered = redactionList.filter(Boolean); + // 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( `(${filtered.map(escapeRegExp).join('|')})`, From 469a47250e5fcbb084ea847bd1893d65e152c8f2 Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Tue, 16 Nov 2021 13:45:16 +0100 Subject: [PATCH 103/118] Remove comment Signed-off-by: Philipp Hugenroth --- packages/core-components/src/layout/Header/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 01baf50ad2..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: theme.shadows[4], // '0 0 8px 3px rgba(20, 20, 20, 0.3)', + boxShadow: theme.shadows[4], position: 'relative', zIndex: 100, display: 'flex', From 0da0143e85ab83d6ee0bd783001a3de496cbe4c1 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Nov 2021 14:16:55 +0100 Subject: [PATCH 104/118] core-app-api: Add missing exports and API annotations Signed-off-by: Johan Haals --- packages/core-app-api/api-report.md | 104 ++++++++++++++---- .../auth/github/GithubAuth.test.ts | 2 +- .../implementations/auth/github/GithubAuth.ts | 3 + .../src/apis/implementations/auth/index.ts | 1 + .../implementations/auth/oauth2/OAuth2.ts | 11 +- .../apis/implementations/auth/oauth2/types.ts | 1 + .../auth/onelogin/OneLoginAuth.ts | 8 +- .../implementations/auth/onelogin/index.ts | 1 + .../implementations/auth/saml/SamlAuth.ts | 3 + .../apis/implementations/auth/saml/index.ts | 1 + .../src/apis/implementations/auth/types.ts | 8 ++ .../src/apis/system/ApiFactoryRegistry.ts | 6 +- .../src/apis/system/ApiProvider.tsx | 6 +- .../core-app-api/src/apis/system/index.ts | 2 + packages/core-app-api/src/app/types.ts | 9 +- 15 files changed, 136 insertions(+), 30 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 2c390bf132..1b9e66cd39 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -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); @@ -204,10 +212,6 @@ export type AppOptions = { bindRoutes?(context: { bind: AppRouteBinder }): void; }; -// Warning: (ae-forgotten-export) The symbol "PartialKeys" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "TargetRouteMap" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "KeysWithType" needs to be exported by the entry point index.d.ts -// // @public export type AppRouteBinder = < ExternalRoutes extends { @@ -238,8 +242,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 +263,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[]; @@ -378,6 +389,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({ @@ -440,6 +453,16 @@ export class GoogleAuth { }: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } +// @public +export type KeysWithType< + Obj extends { + [key in string]: any; + }, + Type, +> = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) @@ -479,12 +502,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 +515,7 @@ export class OAuth2 oauthRequestApi, defaultScopes, scopeTransform, - }: CreateOptions): OAuth2; + }: OAuth2CreateOptions): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -515,6 +537,11 @@ export class OAuth2 signOut(): Promise; } +// @public +export type OAuth2CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + // @public export type OAuth2Session = { providerInfo: { @@ -527,6 +554,12 @@ export type OAuth2Session = { backstageIdentity: BackstageIdentity; }; +// @public +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + // @public export class OAuthRequestManager implements OAuthRequestApi { // (undocumented) @@ -549,25 +582,39 @@ 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 type PartialKeys< + Map extends { + [name in string]: any; + }, + Keys extends keyof Map, +> = Partial> & Required>; + // @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 +635,13 @@ export class SamlAuth signOut(): Promise; } +// @public +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // @public export type SignInPageProps = { onResult(result: SignInResult): void; @@ -601,6 +655,20 @@ export type SignInResult = { signOut?: () => Promise; }; +// @public +export type TargetRouteMap< + ExternalRoutes extends { + [name: string]: ExternalRouteRef; + }, +> = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef | SubRouteRef + : never; +}; + // @public export class UnhandledErrorForwarder { static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; @@ -632,8 +700,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/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..39f7eb3b1b 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -152,23 +152,26 @@ export type AppConfigLoader = () => Promise; /** * Extracts a union of the keys in a map whose value extends the given type + * @public */ -type KeysWithType = { +export type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; }[keyof Obj]; /** * Takes a map Map required values and makes all keys matching Keys optional + * @public */ -type PartialKeys< +export type PartialKeys< Map extends { [name in string]: any }, Keys extends keyof Map, > = Partial> & Required>; /** * Creates a map of target routes with matching parameters based on a map of external routes. + * @public */ -type TargetRouteMap< +export type TargetRouteMap< ExternalRoutes extends { [name: string]: ExternalRouteRef }, > = { [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< From 10a5e819ddd6f844132c3f3bd09cded5b3861c1c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Nov 2021 14:36:13 +0100 Subject: [PATCH 105/118] Unexport internal utility types Signed-off-by: Johan Haals --- packages/core-app-api/api-report.md | 36 +++----------------------- packages/core-app-api/src/app/types.ts | 9 +++---- 2 files changed, 7 insertions(+), 38 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 1b9e66cd39..e5135955dd 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -212,6 +212,10 @@ export type AppOptions = { bindRoutes?(context: { bind: AppRouteBinder }): void; }; +// Warning: (ae-forgotten-export) The symbol "PartialKeys" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "TargetRouteMap" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "KeysWithType" needs to be exported by the entry point index.d.ts +// // @public export type AppRouteBinder = < ExternalRoutes extends { @@ -453,16 +457,6 @@ export class GoogleAuth { }: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } -// @public -export type KeysWithType< - Obj extends { - [key in string]: any; - }, - Type, -> = { - [key in keyof Obj]: Obj[key] extends Type ? key : never; -}[keyof Obj]; - // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) @@ -601,14 +595,6 @@ export type OneLoginAuthCreateOptions = { }; }; -// @public -export type PartialKeys< - Map extends { - [name in string]: any; - }, - Keys extends keyof Map, -> = Partial> & Required>; - // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi @@ -655,20 +641,6 @@ export type SignInResult = { signOut?: () => Promise; }; -// @public -export type TargetRouteMap< - ExternalRoutes extends { - [name: string]: ExternalRouteRef; - }, -> = { - [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< - infer Params, - any - > - ? RouteRef | SubRouteRef - : never; -}; - // @public export class UnhandledErrorForwarder { static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 39f7eb3b1b..f2d1976f87 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -152,26 +152,23 @@ export type AppConfigLoader = () => Promise; /** * Extracts a union of the keys in a map whose value extends the given type - * @public */ -export type KeysWithType = { +type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; }[keyof Obj]; /** * Takes a map Map required values and makes all keys matching Keys optional - * @public */ -export type PartialKeys< +type PartialKeys< Map extends { [name in string]: any }, Keys extends keyof Map, > = Partial> & Required>; /** * Creates a map of target routes with matching parameters based on a map of external routes. - * @public */ -export type TargetRouteMap< +type TargetRouteMap< ExternalRoutes extends { [name: string]: ExternalRouteRef }, > = { [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< From 32bfbafb0fdf1b8be0599c641cea70a295b844f7 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 16 Nov 2021 14:38:47 +0100 Subject: [PATCH 106/118] Add changeset Signed-off-by: Johan Haals --- .changeset/rotten-clouds-kick.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rotten-clouds-kick.md 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. From 33741318d5f0aaf1695bf02b433a74b8f6118df4 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 16 Nov 2021 19:52:05 +0530 Subject: [PATCH 107/118] Add unity as adopter Signed-off-by: Himanshu Mishra --- ADOPTERS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index f5f5e260c9..86ea27ef39 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -66,3 +66,5 @@ | [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 | | [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. | From 51029633af38b36571c1ace492231da4366b0501 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 16 Nov 2021 20:32:42 +0530 Subject: [PATCH 108/118] fix formatting of the adopters file Signed-off-by: Himanshu Mishra --- ADOPTERS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 86ea27ef39..d075c25cb6 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -66,5 +66,4 @@ | [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 | | [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. | From 4576f82858210146123d0378a91fb006c14f69c0 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 16 Nov 2021 08:31:45 -0700 Subject: [PATCH 109/118] Fix sample data in example Cost Insights client Signed-off-by: Tim Hansen --- .changeset/cost-insights-two-crabs-evolve.md | 5 +++++ plugins/cost-insights/src/testUtils/testUtils.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/cost-insights-two-crabs-evolve.md 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/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; From 59387f759385bc39f3a659a90b966b5dd33f81f5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 17:46:53 +0100 Subject: [PATCH 110/118] changesets: fix reference to missing package Signed-off-by: Patrik Oldsberg --- .changeset/techdocs-orange-cougars-relax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/techdocs-orange-cougars-relax.md b/.changeset/techdocs-orange-cougars-relax.md index 3776c9ea15..376de6a0e5 100644 --- a/.changeset/techdocs-orange-cougars-relax.md +++ b/.changeset/techdocs-orange-cougars-relax.md @@ -1,6 +1,6 @@ --- '@backstage/techdocs-common': patch -'@backstage/techdocs-backend': 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 From c0e64a685f79cdf9b2a46951abe001e0d076b2c6 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 16 Nov 2021 19:45:22 +0100 Subject: [PATCH 111/118] test(techdocs-cli): kill detached child process Signed-off-by: Camila Belo --- .github/workflows/techdocs-e2e.yml | 2 +- packages/techdocs-cli/package.json | 6 ++- .../techdocs-cli/src/commands/serve/serve.ts | 7 +--- packages/techdocs-cli/src/e2e.test.ts | 38 +++++++++++++------ scripts/techdocs-cli.js | 2 +- yarn.lock | 9 +++++ 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/.github/workflows/techdocs-e2e.yml b/.github/workflows/techdocs-e2e.yml index 8f166515ff..191274ec84 100644 --- a/.github/workflows/techdocs-e2e.yml +++ b/.github/workflows/techdocs-e2e.yml @@ -39,4 +39,4 @@ jobs: - name: techdocs-cli e2e test working-directory: packages/techdocs-cli - run: yarn test:e2e + run: yarn test:e2e:ci diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 0f18903282..a24a1be0f8 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -24,8 +24,9 @@ "build": "./scripts/build.sh", "clean": "backstage-cli clean", "lint": "backstage-cli lint", - "test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts", - "test:e2e": "backstage-cli test --testPathPattern src/e2e.test.ts --runInBand" + "test": "backstage-cli test --testPathIgnorePatterns=src/e2e.test.ts", + "test:e2e": "backstage-cli test src/e2e.test.ts", + "test:e2e:ci": "backstage-cli test --watchAll=false --ci src/e2e.test.ts" }, "bin": { "techdocs-cli": "bin/techdocs-cli" @@ -41,6 +42,7 @@ "@types/serve-handler": "^6.1.0", "@types/webpack-env": "^1.15.3", "embedded-techdocs-app": "0.0.0", + "find-process": "^1.4.5", "nodemon": "^2.0.2", "ts-node": "^10.0.0" }, diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 5fed2dd56e..1182c850b3 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -121,10 +121,5 @@ export default async function serve(cmd: Command) { ); }); - try { - await waitForSignal([mkdocsChildProcess]); - process.exit(0); - } catch { - process.exit(1); - } + await waitForSignal([mkdocsChildProcess]); } diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts index e6cf4e84c5..088dbc44b8 100644 --- a/packages/techdocs-cli/src/e2e.test.ts +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -14,13 +14,15 @@ * limitations under the License. */ -import { execSync, spawn } from 'child_process'; +import { execSync, spawn, SpawnOptionsWithoutStdio } from 'child_process'; import path from 'path'; +import findProcess from 'find-process'; + const executeCommand = ( command: string, args: string[], - options?: Object, + options?: SpawnOptionsWithoutStdio, ): Promise<{ exit: number; stdout: string; @@ -29,10 +31,9 @@ const executeCommand = ( return new Promise(resolve => { const stdout: Buffer[] = []; const stderr: Buffer[] = []; - const proc = - process.platform === 'win32' - ? spawn('cmd', ['/s', '/c', command, ...args], options) - : spawn(command, args, options); + + const shell = process.platform === 'win32'; + const proc = spawn(command, args, { ...options, shell }); proc.stdout?.on('data', data => { stdout.push(Buffer.from(data)); @@ -52,9 +53,25 @@ const executeCommand = ( }); }; +const timeout = 25000; + +jest.setTimeout(timeout * 2); + describe('end-to-end', () => { const cwd = path.resolve(__dirname, 'fixture'); + afterEach(async () => { + // On Windows the pid of a spawned process may be wrong + // Because of this, we should be kill the MKDocs after the test + // (e.g. https://github.com/nodejs/node/issues/4289#issuecomment-854270414) + if (process.platform === 'win32') { + const procs = await findProcess('name', 'mkdocs', true); + procs.forEach((proc: { pid: number }) => { + process.kill(proc.pid); + }); + } + }); + beforeAll(() => { execSync('yarn workspace @techdocs/cli link', { stdio: 'ignore' }); }); @@ -64,29 +81,26 @@ describe('end-to-end', () => { }); it('shows help text', async () => { - jest.setTimeout(30000); const proc = await executeCommand('techdocs-cli', ['--help']); expect(proc.stdout).toContain('Usage: techdocs-cli [options]'); expect(proc.exit).toEqual(0); }); it('can generate', async () => { - jest.setTimeout(30000); const proc = await executeCommand( 'techdocs-cli', ['generate', '--no-docker'], - { cwd, timeout: 25000 }, + { cwd, timeout }, ); expect(proc.stdout).toContain('Successfully generated docs'); expect(proc.exit).toEqual(0); }); it('can serve in mkdocs', async () => { - jest.setTimeout(30000); const proc = await executeCommand( 'techdocs-cli', ['serve:mkdocs', '--no-docker'], - { cwd, timeout: 25000 }, + { cwd, timeout }, ); expect(proc.stdout).toContain('Starting mkdocs server'); expect(proc.exit).toEqual(0); @@ -97,7 +111,7 @@ describe('end-to-end', () => { const proc = await executeCommand( 'techdocs-cli', ['serve', '--no-docker'], - { cwd, timeout: 25000 }, + { cwd, timeout }, ); expect(proc.stdout).toContain('Starting mkdocs server'); expect(proc.stdout).toContain('Serving docs in Backstage at'); diff --git a/scripts/techdocs-cli.js b/scripts/techdocs-cli.js index 90dcc40f39..7cc07f15fa 100644 --- a/scripts/techdocs-cli.js +++ b/scripts/techdocs-cli.js @@ -19,7 +19,7 @@ const { execSync } = require('child_process'); const args = process.argv.slice(2); -execSync(`yarn workspace @techdocs/cli build`, { stdio: 'inherit' }); +execSync(`yarn -s workspace @techdocs/cli build`, { stdio: 'inherit' }); execSync(`yarn workspace @techdocs/cli link`, { stdio: 'ignore' }); execSync(`techdocs-cli ${args.join(' ')}`, { stdio: 'inherit' }); execSync(`yarn workspace @techdocs/cli unlink`, { stdio: 'ignore' }); diff --git a/yarn.lock b/yarn.lock index f51a95fccd..b52b045573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14667,6 +14667,15 @@ find-my-way@^2.2.2: safe-regex2 "^2.0.0" semver-store "^0.3.0" +find-process@^1.4.5: + version "1.4.5" + resolved "https://registry.npmjs.org/find-process/-/find-process-1.4.5.tgz#6a0e4c87a32ca927c05cbed7b9078d62ffaac1a4" + integrity sha512-v11rJYYISUWn+s8qZzgGnBvlzRKf3bOtlGFM8H0kw56lGQtOmLuLCzuclA5kehA2j7S5sioOWdI4woT3jDavAw== + dependencies: + chalk "^4.0.0" + commander "^5.1.0" + debug "^4.1.1" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" From c10bb2234334415fac77022fcd4ae5dca4e4ce67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Baroni?= Date: Tue, 16 Nov 2021 16:15:41 -0300 Subject: [PATCH 112/118] Add PicPay as adopter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Luís Baroni --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index d075c25cb6..cc9e178f82 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -67,3 +67,4 @@ | [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. | From f8db7c3988e3424660e1f1428e0ef5e76d6c75d3 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Wed, 17 Nov 2021 09:42:36 +0800 Subject: [PATCH 113/118] remove untouched package from changeset Signed-off-by: Gauthier Roebroeck --- .changeset/fresh-zebras-hug.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/fresh-zebras-hug.md b/.changeset/fresh-zebras-hug.md index b18c6acdfc..c295dc2287 100644 --- a/.changeset/fresh-zebras-hug.md +++ b/.changeset/fresh-zebras-hug.md @@ -1,6 +1,5 @@ --- -"@backstage/plugin-kafka-backend": patch -"@backstage/plugin-kafka": patch +'@backstage/plugin-kafka-backend': patch --- Update Kafka configuration types From 01f74aa87885b1dbc0fdd7a318e35f4a075deb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 17 Nov 2021 10:39:28 +0100 Subject: [PATCH 114/118] Add `AbortSignal` support to `UrlReader` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/proud-bottles-cheat.md | 5 ++ packages/backend-common/api-report.md | 10 ++-- packages/backend-common/package.json | 1 + .../src/reading/AwsS3UrlReader.ts | 29 +++++++----- .../src/reading/AzureUrlReader.ts | 47 +++++++++++-------- .../src/reading/BitbucketUrlReader.ts | 30 +++++++----- .../src/reading/FetchUrlReader.ts | 1 + .../src/reading/GithubUrlReader.ts | 5 +- .../src/reading/GitlabUrlReader.ts | 18 +++++-- packages/backend-common/src/reading/types.ts | 28 +++++++++++ 10 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 .changeset/proud-bottles-cheat.md diff --git a/.changeset/proud-bottles-cheat.md b/.changeset/proud-bottles-cheat.md new file mode 100644 index 0000000000..0c724352c7 --- /dev/null +++ b/.changeset/proud-bottles-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Add `AbortSignal` support to `UrlReader` diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 7242c32a84..e3119e78a8 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -6,6 +6,7 @@ /// /// +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; import { AwsS3Integration } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; @@ -48,7 +49,7 @@ export class AwsS3UrlReader implements UrlReader { // (undocumented) read(url: string): Promise; // (undocumented) - readTree(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) @@ -72,7 +73,7 @@ export class AzureUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -94,7 +95,7 @@ export class BitbucketUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -418,6 +419,7 @@ export type ReadTreeOptions = { }, ): boolean; etag?: string; + signal?: AbortSignal_2; }; // @public @@ -471,6 +473,7 @@ export type ReadTreeResponseFile = { // @public export type ReadUrlOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public @@ -508,6 +511,7 @@ export type RunContainerOptions = { // @public export type SearchOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 03ae1fbe62..16ce62c7d7 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -62,6 +62,7 @@ "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", + "node-abort-controller": "^3.0.1", "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.ts b/packages/backend-common/src/reading/AwsS3UrlReader.ts index 05a14e26ec..0d806fcfaa 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.ts @@ -18,6 +18,7 @@ import aws, { Credentials, S3 } from 'aws-sdk'; import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import { ReaderFactory, + ReadTreeOptions, ReadTreeResponse, ReadTreeResponseFactory, ReadUrlOptions, @@ -92,7 +93,7 @@ export class AwsS3UrlReader implements UrlReader { ) {} /** - * If accesKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: + * If accessKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html */ private static buildCredentials( @@ -154,9 +155,10 @@ export class AwsS3UrlReader implements UrlReader { }; } - const response = this.deps.s3.getObject(params); - const buffer = await getRawBody(response.createReadStream()); - const etag = (await response.promise()).ETag; + const request = this.deps.s3.getObject(params); + options?.signal?.addEventListener('abort', () => request.abort()); + const buffer = await getRawBody(request.createReadStream()); + const etag = (await request.promise()).ETag; return { buffer: async () => buffer, @@ -171,7 +173,10 @@ export class AwsS3UrlReader implements UrlReader { } } - async readTree(url: string): Promise { + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { try { const { path, bucket, region } = parseURL(url); const allObjects: ObjectList = []; @@ -180,13 +185,13 @@ export class AwsS3UrlReader implements UrlReader { let output: ListObjectsV2Output; do { aws.config.update({ region: region }); - output = await this.deps.s3 - .listObjectsV2({ - Bucket: bucket, - ContinuationToken: continuationToken, - Prefix: path, - }) - .promise(); + const request = this.deps.s3.listObjectsV2({ + Bucket: bucket, + ContinuationToken: continuationToken, + Prefix: path, + }); + options?.signal?.addEventListener('abort', () => request.abort()); + output = await request.promise(); if (output.Contents) { output.Contents.forEach(contents => { allObjects.push(contents); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 6015b044bc..82d1192b17 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -55,21 +55,34 @@ export class AzureUrlReader implements UrlReader { ) {} async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not implemented yet. + const { signal } = options ?? {}; + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch( - builtUrl, - getAzureRequestOptions(this.integration.config), - ); + response = await fetch(builtUrl, { + ...getAzureRequestOptions(this.integration.config), + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -79,19 +92,12 @@ export class AzureUrlReader implements UrlReader { throw new Error(message); } - async readUrl( - url: string, - _options?: ReadUrlOptions, - ): Promise { - // TODO etag is not implemented yet. - const buffer = await this.read(url); - return { buffer: async () => buffer }; - } - async readTree( url: string, options?: ReadTreeOptions, ): Promise { + const { etag, filter, signal } = options ?? {}; + // TODO: Support filepath based reading tree feature like other providers // Get latest commit SHA @@ -109,16 +115,16 @@ export class AzureUrlReader implements UrlReader { } const commitSha = (await commitsAzureResponse.json()).value[0].commitId; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } - const archiveAzureResponse = await fetch( - getAzureDownloadUrl(url), - getAzureRequestOptions(this.integration.config, { + const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), { + ...getAzureRequestOptions(this.integration.config, { Accept: 'application/zip', }), - ); + ...(signal && { signal }), + }); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; if (archiveAzureResponse.status === 404) { @@ -139,7 +145,7 @@ export class AzureUrlReader implements UrlReader { return await this.deps.treeResponseFactory.fromZipArchive({ stream: archiveAzureResponse.body as unknown as Readable, etag: commitSha, - filter: options?.filter, + filter, subpath, }); } @@ -158,6 +164,7 @@ export class AzureUrlReader implements UrlReader { const tree = await this.readTree(treeUrl.toString(), { etag: options?.etag, + signal: options?.signal, filter: p => (matcher ? matcher.match(p) : true), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 4ae0ea37a8..c1a113a50d 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -77,18 +77,33 @@ export class BitbucketUrlReader implements UrlReader { } async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not supported yet + const { signal } = options ?? {}; const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config); - const options = getBitbucketRequestOptions(this.integration.config); + const requestOptions = getBitbucketRequestOptions(this.integration.config); let response: Response; try { - response = await fetch(bitbucketUrl.toString(), options); + response = await fetch(bitbucketUrl.toString(), { + ...requestOptions, + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } if (response.ok) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; @@ -98,15 +113,6 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - async readUrl( - url: string, - _options?: ReadUrlOptions, - ): Promise { - // TODO etag is not implemented yet. - const buffer = await this.read(url); - return { buffer: async () => buffer }; - } - async readTree( url: string, options?: ReadTreeOptions, diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 732d3b9f59..8fc378d515 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -73,6 +73,7 @@ export class FetchUrlReader implements UrlReader { headers: { ...(options?.etag && { 'If-None-Match': options.etag }), }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index ca16e23fec..755e0f17e6 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -110,6 +110,7 @@ export class GithubUrlReader implements UrlReader { ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -164,7 +165,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, options, ); } @@ -188,7 +189,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, ); return { files, etag: commitSha }; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 3fc9673e8b..128aed3c85 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -66,6 +66,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadUrlOptions, ): Promise { + const { etag, signal } = options ?? {}; const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; @@ -73,8 +74,9 @@ export class GitlabUrlReader implements UrlReader { response = await fetch(builtUrl, { headers: { ...getGitLabRequestOptions(this.integration.config).headers, - ...(options?.etag && { 'If-None-Match': options.etag }), + ...(etag && { 'If-None-Match': etag }), }, + ...(signal && { signal }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -102,6 +104,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { + const { etag, signal } = options ?? {}; const { ref, full_name, filepath } = parseGitUrl(url); // Use GitLab API to get the default branch @@ -140,7 +143,10 @@ export class GitlabUrlReader implements UrlReader { full_name, )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!commitsGitlabResponse.ok) { const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; @@ -152,7 +158,7 @@ export class GitlabUrlReader implements UrlReader { const commitSha = (await commitsGitlabResponse.json())[0].id; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } @@ -161,7 +167,10 @@ export class GitlabUrlReader implements UrlReader { `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, )}/repository/archive?sha=${branch}`, - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!archiveGitLabResponse.ok) { const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; @@ -191,6 +200,7 @@ export class GitlabUrlReader implements UrlReader { const tree = await this.readTree(treeUrl, { etag: options?.etag, + signal: options?.signal, filter: path => matcher.match(stripFirstDirectoryFromPath(path)), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 7e0904f18b..16edf81ba6 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -17,6 +17,7 @@ import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { AbortSignal } from 'node-abort-controller'; /** * A generic interface for fetching plain data from URLs. @@ -101,6 +102,15 @@ export type ReadUrlOptions = { * of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -165,6 +175,15 @@ export type ReadTreeOptions = { * rest of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -291,6 +310,15 @@ export type SearchOptions = { * search will return the rest of SearchResponse along with a new etag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** From 1e99c73c7570095c1cbaa561aa58a5fb5737dcda Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 17 Nov 2021 11:26:35 +0100 Subject: [PATCH 115/118] config-loader: Change loadConfig return type to object Signed-off-by: Johan Haals --- .changeset/blue-bikes-explode.md | 14 ++ .changeset/hungry-wombats-happen.md | 6 + packages/backend-common/src/config.ts | 12 +- packages/cli/src/lib/config.ts | 2 +- packages/config-loader/api-report.md | 9 +- packages/config-loader/src/index.ts | 1 + packages/config-loader/src/loader.test.ts | 220 ++++++++++++---------- packages/config-loader/src/loader.ts | 21 ++- 8 files changed, 172 insertions(+), 113 deletions(-) create mode 100644 .changeset/blue-bikes-explode.md create mode 100644 .changeset/hungry-wombats-happen.md diff --git a/.changeset/blue-bikes-explode.md b/.changeset/blue-bikes-explode.md new file mode 100644 index 0000000000..1c10a08a46 --- /dev/null +++ b/.changeset/blue-bikes-explode.md @@ -0,0 +1,14 @@ +--- +'@backstage/config-loader': minor +--- + +Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`. + +This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users. + +If you use `loadConfig` directly you will need to update your usage from: + +```diff +- const appConfigs = await loadConfig(options) ++ const { appConfigs } = await loadConfig(options) +``` diff --git a/.changeset/hungry-wombats-happen.md b/.changeset/hungry-wombats-happen.md new file mode 100644 index 0000000000..018e7ae9e8 --- /dev/null +++ b/.changeset/hungry-wombats-happen.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/cli': patch +--- + +Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 3fd5e1b44e..941ad26e65 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -200,7 +200,7 @@ export async function loadBackendConfig(options: { }); const config = new ObservableConfigProxy(options.logger); - const configs = await loadConfig({ + const { appConfigs } = await loadConfig({ configRoot: paths.targetRoot, configPaths: [], configTargets: configTargets, @@ -227,14 +227,16 @@ export async function loadBackendConfig(options: { }); options.logger.info( - `Loaded config from ${configs.map(c => c.context).join(', ')}`, + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, ); - config.setConfig(ConfigReader.fromConfigs(configs)); + config.setConfig(ConfigReader.fromConfigs(appConfigs)); // Subscribe to config changes and update the redaction list for logging - updateRedactionList(schema, configs, options.logger); - config.subscribe(() => updateRedactionList(schema, configs, options.logger)); + updateRedactionList(schema, appConfigs, options.logger); + config.subscribe(() => + updateRedactionList(schema, appConfigs, options.logger), + ); return config; } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 6f93318ead..d343a554f8 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -54,7 +54,7 @@ export async function loadCliConfig(options: Options) { packagePaths: [paths.resolveTargetRoot('package.json')], }); - const appConfigs = await loadConfig({ + const { appConfigs } = await loadConfig({ experimentalEnvFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 6f568df942..e0fab50e5b 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -38,7 +38,9 @@ export type ConfigTarget = export type ConfigVisibility = 'frontend' | 'backend' | 'secret'; // @public -export function loadConfig(options: LoadConfigOptions): Promise; +export function loadConfig( + options: LoadConfigOptions, +): Promise; // @public export type LoadConfigOptions = { @@ -66,6 +68,11 @@ export type LoadConfigOptionsWatch = { stopSignal?: Promise; }; +// @public +export type LoadConfigResult = { + appConfigs: AppConfig[]; +}; + // @public export function loadConfigSchema( options: LoadConfigSchemaOptions, diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 97f0d301a3..2699b3571f 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -34,4 +34,5 @@ export type { LoadConfigOptions, LoadConfigOptionsWatch, LoadConfigOptionsRemote, + LoadConfigResult, } from './loader'; diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 804254afa0..b5c986eeb9 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -122,18 +122,20 @@ describe('loadConfig', () => { configTargets: [], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('load config from remote path', async () => { @@ -151,18 +153,20 @@ describe('loadConfig', () => { reloadIntervalSeconds: 30, }, }), - ).resolves.toEqual([ - { - context: configUrl, - data: { - app: { - title: 'Remote Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads config with secrets from two different files', async () => { @@ -173,28 +177,30 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.yaml' }], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - { - context: 'app-config2.yaml', - data: { - app: { - title: 'Example App 2', - sessionKey: 'abc123', - escaped: '${Escaped}', + { + context: 'app-config2.yaml', + data: { + app: { + title: 'Example App 2', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads config with secrets from single file', async () => { @@ -205,18 +211,20 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.yaml' }], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads development config with secrets', async () => { @@ -230,34 +238,36 @@ describe('loadConfig', () => { ], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', - }, - }, - }, - { - context: 'app-config.development.yaml', - data: { - app: { - sessionKey: 'development-key', - }, - backend: { - foo: { - bar: 'token is-secret', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', }, }, - other: { - secret: 'abc123', + }, + { + context: 'app-config.development.yaml', + data: { + app: { + sessionKey: 'development-key', + }, + backend: { + foo: { + bar: 'token is-secret', + }, + }, + other: { + secret: 'abc123', + }, }, }, - }, - ]); + ], + }); }); it('loads deep substituted config', async () => { @@ -268,19 +278,21 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.substitute.yaml' }], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.substitute.yaml', - data: { - app: { - someConfig: { - secret: '123abc', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.substitute.yaml', + data: { + app: { + someConfig: { + secret: '123abc', + }, + noSubstitute: 'notSubstituted', }, - noSubstitute: 'notSubstituted', }, }, - }, - ]); + ], + }); }); it('watches config files', async () => { @@ -297,18 +309,20 @@ describe('loadConfig', () => { stopSignal: stopSignal.promise, }, }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); await fs.writeJson('/root/app-config.yaml', { app: { @@ -349,18 +363,20 @@ describe('loadConfig', () => { reloadIntervalSeconds: 1, }, }), - ).resolves.toEqual([ - { - context: configUrl, - data: { - app: { - title: 'Remote Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); server.use(reloadHandler); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 4a5660f46c..6a92ed519d 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -88,6 +88,17 @@ export type LoadConfigOptions = { watch?: LoadConfigOptionsWatch; }; +/** + * Results of loading configuration files. + * @public + */ +export type LoadConfigResult = { + /** + * Array of all loaded configs. + */ + appConfigs: AppConfig[]; +}; + /** * Load configuration data. * @@ -95,7 +106,7 @@ export type LoadConfigOptions = { */ export async function loadConfig( options: LoadConfigOptions, -): Promise { +): Promise { const { configRoot, experimentalEnvFunc: envFunc, watch, remote } = options; const configPaths: string[] = options.configTargets @@ -290,7 +301,9 @@ export async function loadConfig( watchRemoteConfig(watch, remote); } - return remote - ? [...remoteConfigs, ...fileConfigs, ...envConfigs] - : [...fileConfigs, ...envConfigs]; + return { + appConfigs: remote + ? [...remoteConfigs, ...fileConfigs, ...envConfigs] + : [...fileConfigs, ...envConfigs], + }; } From e2666875801e132b27ebd1d8cbf18d5994fe7125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20MORI?= Date: Wed, 17 Nov 2021 11:45:02 +0100 Subject: [PATCH 116/118] Update changeset to switch from minor to patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane MORI --- .../{silver-plums-speak.md => techdocs-silver-plums-speak.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .changeset/{silver-plums-speak.md => techdocs-silver-plums-speak.md} (90%) diff --git a/.changeset/silver-plums-speak.md b/.changeset/techdocs-silver-plums-speak.md similarity index 90% rename from .changeset/silver-plums-speak.md rename to .changeset/techdocs-silver-plums-speak.md index 2277b7bf12..84d04fc1ef 100644 --- a/.changeset/silver-plums-speak.md +++ b/.changeset/techdocs-silver-plums-speak.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-techdocs': minor +'@backstage/plugin-techdocs': patch --- Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. From c9e19092a7dc6caf9801d3245fcdc3ab2b84adfb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 17:26:41 +0100 Subject: [PATCH 117/118] api-extractor: add support for @ignore Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 112 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index c8f35508fa..328240316c 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -30,8 +30,15 @@ import { ExtractorConfig, CompilerState, ExtractorLogLevel, + ExtractorMessage, } from '@microsoft/api-extractor'; -import { DocNode, IDocNodeContainerParameters } from '@microsoft/tsdoc'; +import { Program } from 'typescript'; +import { + DocNode, + IDocNodeContainerParameters, + TSDocTagSyntaxKind, +} from '@microsoft/tsdoc'; +import { TSDocConfigFile } from '@microsoft/tsdoc-config'; import { ApiPackage, ApiModel } from '@microsoft/api-extractor-model'; import { IMarkdownDocumenterOptions, @@ -42,6 +49,7 @@ import { DocTableRow } from '@microsoft/api-documenter/lib/nodes/DocTableRow'; import { DocHeading } from '@microsoft/api-documenter/lib/nodes/DocHeading'; import { CustomMarkdownEmitter } from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter'; import { IMarkdownEmitterContext } from '@microsoft/api-documenter/lib/markdown/MarkdownEmitter'; +import { AstDeclaration } from '@microsoft/api-extractor/lib/analyzer/AstDeclaration'; const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); @@ -79,11 +87,96 @@ const { ApiReportGenerator, } = require('@microsoft/api-extractor/lib/generators/ApiReportGenerator'); +function patchFileMessageFetcher( + router: any, + transform: (messages: ExtractorMessage[], ast?: AstDeclaration) => void, +) { + const { + fetchAssociatedMessagesForReviewFile, + fetchUnassociatedMessagesForReviewFile, + } = router; + + router.fetchAssociatedMessagesForReviewFile = + function patchedFetchAssociatedMessagesForReviewFile(ast) { + const messages = fetchAssociatedMessagesForReviewFile.call(this, ast); + return transform(messages, ast); + }; + router.fetchUnassociatedMessagesForReviewFile = + function patchedFetchUnassociatedMessagesForReviewFile() { + const messages = fetchUnassociatedMessagesForReviewFile.call(this); + return transform(messages); + }; +} + const originalGenerateReviewFileContent = ApiReportGenerator.generateReviewFileContent; ApiReportGenerator.generateReviewFileContent = - function decoratedGenerateReviewFileContent(...args) { - const content = originalGenerateReviewFileContent.apply(this, args); + function decoratedGenerateReviewFileContent(collector, ...moreArgs) { + const program = collector.program as Program; + + // The purpose of this override is to allow the @ignore tag to be used to ignore warnings + // of the form "Warning: (ae-forgotten-export) The symbol "FooBar" needs to be exported by the entry point index.d.ts" + patchFileMessageFetcher( + collector.messageRouter, + (messages: ExtractorMessage[]) => { + return messages.filter(message => { + if (message.messageId !== 'ae-forgotten-export') { + return true; + } + + // Symbol name has to be extracted from the message :( + // There's frequently no AST for these exports because type literals + // aren't traversed by the generator. + const symbolMatch = message.text.match(/The symbol "([^"]+)"/); + if (!symbolMatch) { + throw new Error( + `Failed to extract symbol name from message "${message.text}"`, + ); + } + const [, symbolName] = symbolMatch; + + const sourceFile = program.getSourceFile(message.sourceFilePath); + if (!sourceFile) { + throw new Error( + `Failed to find source file in program at path "${message.sourceFilePath}"`, + ); + } + + // NOTE: we limit the @internal functionality to only apply to types that are declared + // in the same module as where they're being referenced from. This limitation makes + // the implementation here simpler but could be revisited if needed. + + // The local name of the symbol within the file, rather than the exported name + const localName = (sourceFile as any).identifiers?.get(symbolName); + if (!localName) { + return true; + } + // The local AST node of the export that we're missing + const local = (sourceFile as any).locals?.get(localName); + if (!local) { + return true; + } + + // If any of the TSDoc comments contain a @ignore tag, we ignore this message + const isIgnored = local.declarations.some(declaration => { + const tags = [declaration.jsDoc] + .flat() + .filter(Boolean) + .flatMap((tagNode: any) => tagNode.tags); + + return tags.some(tag => tag?.tagName.text === 'ignore'); + }); + + return !isIgnored; + }); + }, + ); + + const content = originalGenerateReviewFileContent.call( + this, + collector, + ...moreArgs, + ); return prettier.format(content, { ...require('@spotify/prettier-config'), parser: 'markdown', @@ -134,6 +227,18 @@ async function findPackageDirs() { return packageDirs; } +async function getTsDocConfig() { + const tsdocConfigFile = await TSDocConfigFile.loadFile( + require.resolve('@microsoft/api-extractor/extends/tsdoc-base.json'), + ); + tsdocConfigFile.addTagDefinition({ + tagName: '@ignore', + syntaxKind: TSDocTagSyntaxKind.ModifierTag, + }); + tsdocConfigFile.setSupportForTag('@ignore', true); + return tsdocConfigFile; +} + function logApiReportInstructions() { console.log(''); console.log( @@ -236,6 +341,7 @@ async function runApiExtraction({ }, configObjectFullPath: projectFolder, packageJsonFullPath: resolvePath(projectFolder, 'package.json'), + tsdocConfigFile: await getTsDocConfig(), }); // The `packageFolder` needs to point to the location within `dist-types` in order for relative From a4c46863d58d828eed76fe321091824765b2f162 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Nov 2021 18:27:24 +0100 Subject: [PATCH 118/118] core-app-api: explicitly do not export TargetRouteMap and friends Signed-off-by: Patrik Oldsberg --- packages/core-app-api/api-report.md | 4 ---- packages/core-app-api/src/app/types.ts | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 48b89a6951..a67d2a2225 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -219,10 +219,6 @@ export type AppOptions = { bindRoutes?(context: { bind: AppRouteBinder }): void; }; -// Warning: (ae-forgotten-export) The symbol "PartialKeys" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "TargetRouteMap" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "KeysWithType" needs to be exported by the entry point index.d.ts -// // @public export type AppRouteBinder = < ExternalRoutes extends { diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 7166136054..f16c8ac656 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -152,6 +152,8 @@ export type AppConfigLoader = () => Promise; /** * Extracts a union of the keys in a map whose value extends the given type + * + * @ignore */ type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; @@ -159,6 +161,8 @@ type KeysWithType = { /** * Takes a map Map required values and makes all keys matching Keys optional + * + * @ignore */ type PartialKeys< Map extends { [name in string]: any }, @@ -167,6 +171,8 @@ type PartialKeys< /** * Creates a map of target routes with matching parameters based on a map of external routes. + * + * @ignore */ type TargetRouteMap< ExternalRoutes extends { [name: string]: ExternalRouteRef },