diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 51913e9b9e..ef43d7bff7 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -21,3 +21,4 @@ */ export * from './git'; +export * from './roles'; diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 2ab0edb68a..c01d76c11f 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -17,7 +17,7 @@ import path from 'path'; import { getPackages, Package } from '@manypkg/get-packages'; import { paths } from '../paths'; -import { PackageRole } from '../role'; +import { PackageRole } from '../roles'; import { GitUtils } from '../git'; import { Lockfile } from './Lockfile'; import { JsonValue } from '@backstage/types'; diff --git a/packages/cli-node/src/role/PackageRoles.ts b/packages/cli-node/src/role/PackageRoles.ts deleted file mode 100644 index 685848fd26..0000000000 --- a/packages/cli-node/src/role/PackageRoles.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2022 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 { z } from 'zod'; -import fs from 'fs-extra'; -import { OptionValues } from 'commander'; -import { paths } from '../paths'; -import { PackageRole, PackageRoleInfo } from './types'; - -const packageRoleInfos: PackageRoleInfo[] = [ - { - role: 'frontend', - platform: 'web', - output: ['bundle'], - }, - { - role: 'backend', - platform: 'node', - output: ['bundle'], - }, - { - role: 'cli', - platform: 'node', - output: ['cjs'], - }, - { - role: 'web-library', - platform: 'web', - output: ['types', 'esm'], - }, - { - role: 'node-library', - platform: 'node', - output: ['types', 'cjs'], - }, - { - role: 'common-library', - platform: 'common', - output: ['types', 'esm', 'cjs'], - }, - { - role: 'frontend-plugin', - platform: 'web', - output: ['types', 'esm'], - }, - { - role: 'frontend-plugin-module', - platform: 'web', - output: ['types', 'esm'], - }, - { - role: 'backend-plugin', - platform: 'node', - output: ['types', 'cjs'], - }, - { - role: 'backend-plugin-module', - platform: 'node', - output: ['types', 'cjs'], - }, -]; - -export function getRoleInfo(role: string): PackageRoleInfo { - const roleInfo = packageRoleInfos.find(r => r.role === role); - if (!roleInfo) { - throw new Error(`Unknown package role '${role}'`); - } - return roleInfo; -} - -const readSchema = z.object({ - name: z.string().optional(), - backstage: z - .object({ - role: z.string().optional(), - }) - .optional(), -}); - -export function getRoleFromPackage(pkgJson: unknown): PackageRole | undefined { - const pkg = readSchema.parse(pkgJson); - - // If there's an explicit role, use that. - if (pkg.backstage) { - const { role } = pkg.backstage; - if (!role) { - throw new Error( - `Package ${pkg.name} must specify a role in the "backstage" field`, - ); - } - - return getRoleInfo(role).role; - } - - return undefined; -} - -export async function findRoleFromCommand( - opts: OptionValues, -): Promise { - if (opts.role) { - return getRoleInfo(opts.role)?.role; - } - - const pkg = await fs.readJson(paths.resolveTarget('package.json')); - const info = getRoleFromPackage(pkg); - if (!info) { - throw new Error(`Target package must have 'backstage.role' set`); - } - return info; -} - -const detectionSchema = z.object({ - name: z.string().optional(), - scripts: z - .object({ - start: z.string().optional(), - build: z.string().optional(), - }) - .optional(), - publishConfig: z - .object({ - main: z.string().optional(), - types: z.string().optional(), - module: z.string().optional(), - }) - .optional(), - main: z.string().optional(), - types: z.string().optional(), - module: z.string().optional(), -}); - -export function detectRoleFromPackage( - pkgJson: unknown, -): PackageRole | undefined { - const pkg = detectionSchema.parse(pkgJson); - - if (pkg.scripts?.start?.includes('app:serve')) { - return 'frontend'; - } - if (pkg.scripts?.build?.includes('backend:bundle')) { - return 'backend'; - } - if (pkg.name?.includes('plugin-') && pkg.name?.includes('-backend-module-')) { - return 'backend-plugin-module'; - } - if (pkg.name?.includes('plugin-') && pkg.name?.includes('-module-')) { - return 'frontend-plugin-module'; - } - if (pkg.scripts?.start?.includes('plugin:serve')) { - return 'frontend-plugin'; - } - if (pkg.scripts?.start?.includes('backend:dev')) { - return 'backend-plugin'; - } - - const mainEntry = pkg.publishConfig?.main || pkg.main; - const moduleEntry = pkg.publishConfig?.module || pkg.module; - const typesEntry = pkg.publishConfig?.types || pkg.types; - if (typesEntry) { - if (mainEntry && moduleEntry) { - return 'common-library'; - } - if (moduleEntry || mainEntry?.endsWith('.esm.js')) { - return 'web-library'; - } - if (mainEntry) { - return 'node-library'; - } - } else if (mainEntry) { - return 'cli'; - } - - return undefined; -} diff --git a/packages/cli-node/src/role/PackageRoles.test.ts b/packages/cli-node/src/roles/PackageRoles.test.ts similarity index 82% rename from packages/cli-node/src/role/PackageRoles.test.ts rename to packages/cli-node/src/roles/PackageRoles.test.ts index 4157c42bfc..80e320f1f4 100644 --- a/packages/cli-node/src/role/PackageRoles.test.ts +++ b/packages/cli-node/src/roles/PackageRoles.test.ts @@ -14,30 +14,23 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import { Command } from 'commander'; -import { - getRoleInfo, - getRoleFromPackage, - findRoleFromCommand, - detectRoleFromPackage, -} from './PackageRoles'; +import { PackageRoles } from './PackageRoles'; describe('getRoleInfo', () => { it('provides role info by role', () => { - expect(getRoleInfo('web-library')).toEqual({ + expect(PackageRoles.getRoleInfo('web-library')).toEqual({ role: 'web-library', platform: 'web', output: ['types', 'esm'], }); - expect(getRoleInfo('frontend')).toEqual({ + expect(PackageRoles.getRoleInfo('frontend')).toEqual({ role: 'frontend', platform: 'web', output: ['bundle'], }); - expect(() => getRoleInfo('invalid')).toThrow( + expect(() => PackageRoles.getRoleInfo('invalid')).toThrow( `Unknown package role 'invalid'`, ); }); @@ -46,7 +39,7 @@ describe('getRoleInfo', () => { describe('getRoleFromPackage', () => { it('reads explicit package roles', () => { expect( - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ backstage: { role: 'web-library', }, @@ -54,7 +47,7 @@ describe('getRoleFromPackage', () => { ).toEqual('web-library'); expect( - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ backstage: { role: 'frontend', }, @@ -62,14 +55,14 @@ describe('getRoleFromPackage', () => { ).toEqual('frontend'); expect(() => - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ name: 'test', backstage: {}, }), ).toThrow('Package test must specify a role in the "backstage" field'); expect(() => - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ name: 'test', backstage: { role: 'invalid' }, }), @@ -77,48 +70,10 @@ describe('getRoleFromPackage', () => { }); }); -describe('findRoleFromCommand', () => { - function mkCommand(args: string) { - const parsed = new Command() - .option('--role ', 'test role') - .parse(['node', 'entry.js', ...args.split(' ')]) as Command; - return parsed.opts(); - } - - beforeEach(() => { - mockFs({ - 'package.json': JSON.stringify({ - name: 'test', - backstage: { - role: 'web-library', - }, - }), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('provides role info by role', async () => { - await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( - 'web-library', - ); - - await expect( - findRoleFromCommand(mkCommand('--role node-library')), - ).resolves.toEqual('node-library'); - - await expect( - findRoleFromCommand(mkCommand('--role invalid')), - ).rejects.toThrow(`Unknown package role 'invalid'`); - }); -}); - describe('detectRoleFromPackage', () => { it('detects the role of example-app', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: 'example-app', private: true, bundled: true, @@ -141,7 +96,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of example-backend', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: 'example-backend', main: 'dist/index.cjs.js', types: 'src/index.ts', @@ -160,7 +115,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog', main: 'src/index.ts', types: 'src/index.ts', @@ -185,7 +140,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-backend', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-backend', main: 'src/index.ts', types: 'src/index.ts', @@ -209,7 +164,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-react', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-react', main: 'src/index.ts', types: 'src/index.ts', @@ -232,7 +187,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-common', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-common', main: 'src/index.ts', types: 'src/index.ts', @@ -256,7 +211,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-backend-module-ldap', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-backend-module-ldap', main: 'src/index.ts', types: 'src/index.ts', @@ -279,7 +234,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-permission-node', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-permission-node', main: 'src/index.ts', types: 'src/index.ts', @@ -303,7 +258,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-analytics-module-ga', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-analytics-module-ga', main: 'src/index.ts', types: 'src/index.ts', diff --git a/packages/cli-node/src/roles/PackageRoles.ts b/packages/cli-node/src/roles/PackageRoles.ts new file mode 100644 index 0000000000..401568ad6d --- /dev/null +++ b/packages/cli-node/src/roles/PackageRoles.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2022 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 { z } from 'zod'; +import { PackageRole, PackageRoleInfo } from './types'; + +const packageRoleInfos: PackageRoleInfo[] = [ + { + role: 'frontend', + platform: 'web', + output: ['bundle'], + }, + { + role: 'backend', + platform: 'node', + output: ['bundle'], + }, + { + role: 'cli', + platform: 'node', + output: ['cjs'], + }, + { + role: 'web-library', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'node-library', + platform: 'node', + output: ['types', 'cjs'], + }, + { + role: 'common-library', + platform: 'common', + output: ['types', 'esm', 'cjs'], + }, + { + role: 'frontend-plugin', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'frontend-plugin-module', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'backend-plugin', + platform: 'node', + output: ['types', 'cjs'], + }, + { + role: 'backend-plugin-module', + platform: 'node', + output: ['types', 'cjs'], + }, +]; + +const readSchema = z.object({ + name: z.string().optional(), + backstage: z + .object({ + role: z.string().optional(), + }) + .optional(), +}); + +const detectionSchema = z.object({ + name: z.string().optional(), + scripts: z + .object({ + start: z.string().optional(), + build: z.string().optional(), + }) + .optional(), + publishConfig: z + .object({ + main: z.string().optional(), + types: z.string().optional(), + module: z.string().optional(), + }) + .optional(), + main: z.string().optional(), + types: z.string().optional(), + module: z.string().optional(), +}); + +/** + * Utilities for working with Backstage package roles. + * + * @public + */ +export class PackageRoles { + /** + * Get the associated info for a package role. + */ + static getRoleInfo(role: string): PackageRoleInfo { + const roleInfo = packageRoleInfos.find(r => r.role === role); + if (!roleInfo) { + throw new Error(`Unknown package role '${role}'`); + } + return roleInfo; + } + + /** + * Given package JSON data, get the package role. + */ + static getRoleFromPackage(pkgJson: unknown): PackageRole | undefined { + const pkg = readSchema.parse(pkgJson); + + if (pkg.backstage) { + const { role } = pkg.backstage; + if (!role) { + throw new Error( + `Package ${pkg.name} must specify a role in the "backstage" field`, + ); + } + + return this.getRoleInfo(role).role; + } + + return undefined; + } + + /** + * Attempt to detect the role of a package from its package.json. + */ + static detectRoleFromPackage(pkgJson: unknown): PackageRole | undefined { + const pkg = detectionSchema.parse(pkgJson); + + if (pkg.scripts?.start?.includes('app:serve')) { + return 'frontend'; + } + if (pkg.scripts?.build?.includes('backend:bundle')) { + return 'backend'; + } + if ( + pkg.name?.includes('plugin-') && + pkg.name?.includes('-backend-module-') + ) { + return 'backend-plugin-module'; + } + if (pkg.name?.includes('plugin-') && pkg.name?.includes('-module-')) { + return 'frontend-plugin-module'; + } + if (pkg.scripts?.start?.includes('plugin:serve')) { + return 'frontend-plugin'; + } + if (pkg.scripts?.start?.includes('backend:dev')) { + return 'backend-plugin'; + } + + const mainEntry = pkg.publishConfig?.main || pkg.main; + const moduleEntry = pkg.publishConfig?.module || pkg.module; + const typesEntry = pkg.publishConfig?.types || pkg.types; + if (typesEntry) { + if (mainEntry && moduleEntry) { + return 'common-library'; + } + if (moduleEntry || mainEntry?.endsWith('.esm.js')) { + return 'web-library'; + } + if (mainEntry) { + return 'node-library'; + } + } else if (mainEntry) { + return 'cli'; + } + + return undefined; + } +} diff --git a/packages/cli-node/src/role/index.ts b/packages/cli-node/src/roles/index.ts similarity index 85% rename from packages/cli-node/src/role/index.ts rename to packages/cli-node/src/roles/index.ts index 5c668e87dc..93022ba986 100644 --- a/packages/cli-node/src/role/index.ts +++ b/packages/cli-node/src/roles/index.ts @@ -20,9 +20,4 @@ export type { PackageOutputType, PackageRole, } from './types'; -export { - getRoleInfo, - getRoleFromPackage, - findRoleFromCommand, - detectRoleFromPackage, -} from './PackageRoles'; +export { PackageRoles } from './PackageRoles'; diff --git a/packages/cli-node/src/role/types.ts b/packages/cli-node/src/roles/types.ts similarity index 75% rename from packages/cli-node/src/role/types.ts rename to packages/cli-node/src/roles/types.ts index a3961544f4..a913ff29b6 100644 --- a/packages/cli-node/src/role/types.ts +++ b/packages/cli-node/src/roles/types.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +/** + * Backstage package role, see {@link https://backstage.io/docs/local-dev/cli-build-system#package-roles | docs}. + * + * @public + */ export type PackageRole = | 'frontend' | 'backend' @@ -26,9 +31,25 @@ export type PackageRole = | 'backend-plugin' | 'backend-plugin-module'; +/** + * A type of platform that a package can be built for. + * + * @public + */ export type PackagePlatform = 'node' | 'web' | 'common'; + +/** + * The type of output that a package can produce. + * + * @public + */ export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs'; +/** + * Information about a package role. + * + * @public + */ export interface PackageRoleInfo { role: PackageRole; platform: PackagePlatform; diff --git a/packages/cli/src/lib/role.test.ts b/packages/cli/src/lib/role.test.ts new file mode 100644 index 0000000000..adca2d70b8 --- /dev/null +++ b/packages/cli/src/lib/role.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2022 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 mockFs from 'mock-fs'; +import { Command } from 'commander'; +import { findRoleFromCommand } from './role'; + +describe('findRoleFromCommand', () => { + function mkCommand(args: string) { + const parsed = new Command() + .option('--role ', 'test role') + .parse(['node', 'entry.js', ...args.split(' ')]) as Command; + return parsed.opts(); + } + + beforeEach(() => { + mockFs({ + 'package.json': JSON.stringify({ + name: 'test', + backstage: { + role: 'web-library', + }, + }), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('provides role info by role', async () => { + await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( + 'web-library', + ); + + await expect( + findRoleFromCommand(mkCommand('--role node-library')), + ).resolves.toEqual('node-library'); + + await expect( + findRoleFromCommand(mkCommand('--role invalid')), + ).rejects.toThrow(`Unknown package role 'invalid'`); + }); +}); diff --git a/packages/cli/src/lib/role.ts b/packages/cli/src/lib/role.ts new file mode 100644 index 0000000000..2f77f0d880 --- /dev/null +++ b/packages/cli/src/lib/role.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 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 { OptionValues } from 'commander'; +import { paths } from './paths'; + +export async function findRoleFromCommand( + opts: OptionValues, +): Promise { + if (opts.role) { + return getRoleInfo(opts.role)?.role; + } + + const pkg = await fs.readJson(paths.resolveTarget('package.json')); + const info = getRoleFromPackage(pkg); + if (!info) { + throw new Error(`Target package must have 'backstage.role' set`); + } + return info; +}