diff --git a/.changeset/brave-tools-drop.md b/.changeset/brave-tools-drop.md new file mode 100644 index 0000000000..02b6b7ebb1 --- /dev/null +++ b/.changeset/brave-tools-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduced initial support for an experimental `backstage.role` field in package.json, as well as experimental and hidden `migrate` and `script` sub-commands. We do not recommend usage of any of these additions yet. diff --git a/packages/cli/package.json b/packages/cli/package.json index aad08a21eb..41cefe6c53 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -112,7 +112,8 @@ "webpack-node-externals": "^3.0.0", "yaml": "^1.10.0", "yml-loader": "^2.1.0", - "yn": "^4.0.0" + "yn": "^4.0.0", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/backend-common": "^0.10.6", diff --git a/packages/cli/src/commands/build/buildApp.ts b/packages/cli/src/commands/build/buildApp.ts new file mode 100644 index 0000000000..3d86d796b7 --- /dev/null +++ b/packages/cli/src/commands/build/buildApp.ts @@ -0,0 +1,39 @@ +/* + * 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 fs from 'fs-extra'; +import { buildBundle } from '../../lib/bundler'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { loadCliConfig } from '../../lib/config'; +import { paths } from '../../lib/paths'; + +interface BuildAppOptions { + writeStats: boolean; + configPaths: string[]; +} + +export async function buildApp(options: BuildAppOptions) { + const { name } = await fs.readJson(paths.resolveTarget('package.json')); + await buildBundle({ + entry: 'src/index', + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + statsJsonEnabled: options.writeStats, + ...(await loadCliConfig({ + args: options.configPaths, + fromPackage: name, + })), + }); +} diff --git a/packages/cli/src/commands/build/buildBackend.ts b/packages/cli/src/commands/build/buildBackend.ts new file mode 100644 index 0000000000..a4d8858cf8 --- /dev/null +++ b/packages/cli/src/commands/build/buildBackend.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import tar, { CreateOptions } from 'tar'; +import { createDistWorkspace } from '../../lib/packager'; +import { paths } from '../../lib/paths'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { buildPackage, Output } from '../../lib/builder'; + +const BUNDLE_FILE = 'bundle.tar.gz'; +const SKELETON_FILE = 'skeleton.tar.gz'; + +interface BuildBackendOptions { + skipBuildDependencies: boolean; +} + +export async function buildBackend(options: BuildBackendOptions) { + const targetDir = paths.resolveTarget('dist'); + const pkg = await fs.readJson(paths.resolveTarget('package.json')); + + // We build the target package without generating type declarations. + await buildPackage({ outputs: new Set([Output.cjs]) }); + + const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle')); + try { + await createDistWorkspace([pkg.name], { + targetDir: tmpDir, + buildDependencies: !options.skipBuildDependencies, + buildExcludes: [pkg.name], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + skeleton: SKELETON_FILE, + }); + + // We built the target backend package using the regular build process, but the result of + // that has now been packed into the dist workspace, so clean up the dist dir. + await fs.remove(targetDir); + await fs.mkdir(targetDir); + + // Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice. + await fs.move( + resolvePath(tmpDir, SKELETON_FILE), + resolvePath(targetDir, SKELETON_FILE), + ); + + // Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache. + await tar.create( + { + file: resolvePath(targetDir, BUNDLE_FILE), + cwd: tmpDir, + portable: true, + noMtime: true, + gzip: true, + } as CreateOptions & { noMtime: boolean }, + [''], + ); + } finally { + await fs.remove(tmpDir); + } +} diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts new file mode 100644 index 0000000000..8c13b515d7 --- /dev/null +++ b/packages/cli/src/commands/build/command.ts @@ -0,0 +1,57 @@ +/* + * 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 { buildPackage, Output } from '../../lib/builder'; +import { findRoleFromCommand, getRoleInfo } from '../../lib/role'; +import { buildApp } from './buildApp'; +import { buildBackend } from './buildBackend'; + +export async function command(cmd: Command): Promise { + const role = await findRoleFromCommand(cmd); + + if (role === 'app') { + return buildApp({ + configPaths: cmd.config as string[], + writeStats: Boolean(cmd.stats), + }); + } + if (role === 'backend') { + return buildBackend({ + skipBuildDependencies: Boolean(cmd.skipBuildDependencies), + }); + } + + const roleInfo = getRoleInfo(role); + + const outputs = new Set(); + + if (roleInfo.output.includes('cjs')) { + outputs.add(Output.cjs); + } + if (roleInfo.output.includes('esm')) { + outputs.add(Output.esm); + } + if (roleInfo.output.includes('types')) { + outputs.add(Output.types); + } + + return buildPackage({ + outputs, + minify: Boolean(cmd.minify), + useApiExtractor: Boolean(cmd.experimentalTypeBuild), + }); +} diff --git a/packages/cli/src/commands/build/index.ts b/packages/cli/src/commands/build/index.ts new file mode 100644 index 0000000000..680fe9e11d --- /dev/null +++ b/packages/cli/src/commands/build/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { command } from './command'; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 362771f8f2..a33e7d4afe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -18,14 +18,112 @@ import { assertError } from '@backstage/errors'; import { CommanderStatic } from 'commander'; import { exitWithError } from '../lib/errors'; -export function registerCommands(program: CommanderStatic) { - const configOption = [ - '--config ', - 'Config files to load instead of app-config.yaml', - (opt: string, opts: string[]) => [...opts, opt], - Array(), - ] as const; +const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => [...opts, opt], + Array(), +] as const; +export function registerScriptCommand(program: CommanderStatic) { + const command = program + .command('script [command]', { hidden: true }) + .description('Lifecycle scripts for Backstage packages [EXPERIMENTAL]'); + + command + .command('start') + .description('Start a package for local development') + .option(...configOption) + .option('--role ', 'Run the command with an explicit package role') + .option('--check', 'Enable type checking and linting if available') + .option('--inspect', 'Enable debugger in Node.js environments') + .option( + '--inspect-brk', + 'Enable debugger in Node.js environments, breaking before code starts', + ) + .action(lazy(() => import('./start').then(m => m.command))); + + command + .command('build') + .description('Build a package for production deployment or publishing') + .option( + '--minify', + 'Minify the generated code. Does not apply to app or backend packages.', + ) + .option( + '--experimental-type-build', + 'Enable experimental type build. Does not apply to app or backend packages.', + ) + .option( + '--skip-build-dependencies', + 'Skip the automatic building of local dependencies. Applies to backend packages only.', + ) + .option( + '--stats', + 'If bundle stats are available, write them to the output directory. Applies to app packages only.', + ) + .option( + '--config ', + 'Config files to load instead of app-config.yaml. Applies to app packages only.', + (opt: string, opts: string[]) => [...opts, opt], + Array(), + ) + .action(lazy(() => import('./build').then(m => m.command))); + + program + .command('lint') + .option( + '--format ', + 'Lint report output format', + 'eslint-formatter-friendly', + ) + .option('--fix', 'Attempt to automatically fix violations') + .description('Lint a package') + .action(lazy(() => import('./lint').then(m => m.default))); + + program + .command('test') + .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args + .helpOption(', --backstage-cli-help') // Let Jest handle help + .description('Run tests, forwarding args to Jest, defaulting to watch mode') + .action(lazy(() => import('./testCommand').then(m => m.default))); + + command + .command('clean') + .description('Delete cache directories') + .action(lazy(() => import('./clean/clean').then(m => m.default))); + + command + .command('prepack') + .description('Prepares a package for packaging before publishing') + .action(lazy(() => import('./pack').then(m => m.pre))); + + command + .command('postpack') + .description('Restores the changes made by the prepack command') + .action(lazy(() => import('./pack').then(m => m.post))); +} + +export function registerMigrateCommand(program: CommanderStatic) { + const command = program + .command('migrate [command]', { hidden: true }) + .description('Migration utilities [EXPERIMENTAL]'); + + command + .command('package-role') + .description(`Add package role field to packages that don't have it`) + .action(lazy(() => import('./migrate/packageRole').then(m => m.default))); + + command + .command('package-scripts') + .description('Set package scripts according to each package role') + .action( + lazy(() => import('./migrate/packageScripts').then(m => m.command)), + ); +} + +export function registerCommands(program: CommanderStatic) { + // TODO(Rugvip): Deprecate in favor of script variant program .command('app:build') .description('Build an app for a production release') @@ -33,6 +131,7 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('app:serve') .description('Serve an app for local development') @@ -40,6 +139,7 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./app/serve').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:build') .description('Build a backend plugin') @@ -47,6 +147,7 @@ export function registerCommands(program: CommanderStatic) { .option('--experimental-type-build', 'Enable experimental type build') .action(lazy(() => import('./backend/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:bundle') .description('Bundle the backend into a deployment archive') @@ -56,6 +157,7 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:dev') .description('Start local development server with HMR for the backend') @@ -104,6 +206,7 @@ export function registerCommands(program: CommanderStatic) { lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); + // TODO(Rugvip): Deprecate in favor of script variant program .command('plugin:build') .description('Build a plugin') @@ -111,6 +214,7 @@ export function registerCommands(program: CommanderStatic) { .option('--experimental-type-build', 'Enable experimental type build') .action(lazy(() => import('./plugin/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('plugin:serve') .description('Serves the dev/ folder of a plugin') @@ -125,14 +229,16 @@ export function registerCommands(program: CommanderStatic) { .description('Diff an existing plugin with the creation template') .action(lazy(() => import('./plugin/diff').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('build') .description('Build a package for publishing') .option('--outputs ', 'List of formats to output [types,cjs,esm]') .option('--minify', 'Minify the generated code') .option('--experimental-type-build', 'Enable experimental type build') - .action(lazy(() => import('./build').then(m => m.default))); + .action(lazy(() => import('./oldBuild').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('lint') .option( @@ -144,6 +250,7 @@ export function registerCommands(program: CommanderStatic) { .description('Lint a package') .action(lazy(() => import('./lint').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('test') .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args @@ -205,6 +312,9 @@ export function registerCommands(program: CommanderStatic) { .description('Print configuration schema') .action(lazy(() => import('./config/schema').then(m => m.default))); + registerScriptCommand(program); + registerMigrateCommand(program); + program .command('versions:bump') .option( @@ -220,16 +330,19 @@ export function registerCommands(program: CommanderStatic) { .description('Check Backstage package versioning') .action(lazy(() => import('./versions/lint').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('prepack') .description('Prepares a package for packaging before publishing') .action(lazy(() => import('./pack').then(m => m.pre))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('postpack') .description('Restores the changes made by the prepack command') .action(lazy(() => import('./pack').then(m => m.post))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('clean') .description('Delete cache directories') diff --git a/packages/cli/src/commands/migrate/packageRole.ts b/packages/cli/src/commands/migrate/packageRole.ts new file mode 100644 index 0000000000..e8bfb8e7c8 --- /dev/null +++ b/packages/cli/src/commands/migrate/packageRole.ts @@ -0,0 +1,69 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { getPackages } from '@manypkg/get-packages'; +import { paths } from '../../lib/paths'; +import { getRoleFromPackage, detectRoleFromPackage } from '../../lib/role'; + +export default async () => { + const { packages } = await getPackages(paths.targetDir); + + await Promise.all( + packages.map(async ({ dir, packageJson: pkg }) => { + const { name } = pkg; + const existingRole = getRoleFromPackage(pkg); + if (existingRole) { + return; + } + + const detectedRole = detectRoleFromPackage(pkg); + if (!detectedRole) { + console.error(`No role detected for package ${name}`); + return; + } + + console.log(`Detected package role of ${name} as ${detectedRole}`); + + let newPkg = pkg as any; + + const pkgKeys = Object.keys(pkg); + if (pkgKeys.includes('backstage')) { + newPkg.backstage = { + ...newPkg.backstage, + role: detectedRole, + }; + } else { + // We insert the backstage field after one of these fields, otherwise at the end + const index = + Math.max( + pkgKeys.indexOf('version'), + pkgKeys.indexOf('private'), + pkgKeys.indexOf('publishConfig'), + ) + 1 || pkgKeys.length; + + const pkgEntries = Object.entries(pkg); + pkgEntries.splice(index, 0, ['backstage', { role: detectedRole }]); + newPkg = Object.fromEntries(pkgEntries); + } + + await fs.writeJson(resolvePath(dir, 'package.json'), newPkg, { + spaces: 2, + }); + }), + ); +}; diff --git a/packages/cli/src/commands/migrate/packageScripts.ts b/packages/cli/src/commands/migrate/packageScripts.ts new file mode 100644 index 0000000000..a3fdd20018 --- /dev/null +++ b/packages/cli/src/commands/migrate/packageScripts.ts @@ -0,0 +1,69 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { PackageGraph } from '../../lib/monorepo'; +import { getRoleFromPackage, getRoleInfo, PackageRole } from '../../lib/role'; + +const noStartRoles: PackageRole[] = ['cli', 'common-library']; + +export async function command() { + const packages = await PackageGraph.listTargetPackages(); + + await Promise.all( + packages.map(async ({ dir, packageJson }) => { + const role = getRoleFromPackage(packageJson); + if (!role) { + return; + } + + const roleInfo = getRoleInfo(role); + const hasStart = !noStartRoles.includes(role); + const isBundled = roleInfo.output.includes('bundle'); + + const expectedScripts = { + ...(hasStart && { start: 'backstage-cli script start' }), + build: 'backstage-cli script build', + lint: 'backstage-cli script lint', + test: 'backstage-cli script test', + clean: 'backstage-cli script clean', + ...(!isBundled && { + postpack: 'backstage-cli script postpack', + prepack: 'backstage-cli script prepack', + }), + }; + + let changed = false; + const currentScripts: Record = + (packageJson.scripts = packageJson.scripts || {}); + + for (const [name, value] of Object.entries(expectedScripts)) { + if (currentScripts[name] !== value) { + changed = true; + currentScripts[name] = value; + } + } + + if (changed) { + console.log(`Updating scripts for ${packageJson.name}`); + await fs.writeJson(resolvePath(dir, 'package.json'), packageJson, { + spaces: 2, + }); + } + }), + ); +} diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/oldBuild.ts similarity index 100% rename from packages/cli/src/commands/build.ts rename to packages/cli/src/commands/oldBuild.ts diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts new file mode 100644 index 0000000000..6615d5fd46 --- /dev/null +++ b/packages/cli/src/commands/start/command.ts @@ -0,0 +1,53 @@ +/* + * 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 { startBackend } from './startBackend'; +import { startFrontend } from './startFrontend'; +import { findRoleFromCommand } from '../../lib/role'; + +export async function command(cmd: Command): Promise { + const role = await findRoleFromCommand(cmd); + + const options = { + configPaths: cmd.config as string[], + checksEnabled: Boolean(cmd.check), + inspectEnabled: Boolean(cmd.inspect), + inspectBrkEnabled: Boolean(cmd.inspectBrk), + }; + + switch (role) { + case 'backend': + case 'plugin-backend': + case 'plugin-backend-module': + case 'node-library': + return startBackend(options); + case 'app': + return startFrontend({ + ...options, + entry: 'src/index', + verifyVersions: true, + }); + case 'web-library': + case 'plugin-frontend': + case 'plugin-frontend-module': + return startFrontend({ entry: 'dev/index', ...options }); + default: + throw new Error( + `Start command is not supported for package role '${role}'`, + ); + } +} diff --git a/packages/cli/src/commands/start/index.ts b/packages/cli/src/commands/start/index.ts new file mode 100644 index 0000000000..680fe9e11d --- /dev/null +++ b/packages/cli/src/commands/start/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { command } from './command'; diff --git a/packages/cli/src/commands/start/startBackend.ts b/packages/cli/src/commands/start/startBackend.ts new file mode 100644 index 0000000000..61ada7a291 --- /dev/null +++ b/packages/cli/src/commands/start/startBackend.ts @@ -0,0 +1,41 @@ +/* + * 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 fs from 'fs-extra'; +import { paths } from '../../lib/paths'; +import { serveBackend } from '../../lib/bundler'; + +interface StartBackendOptions { + checksEnabled: boolean; + inspectEnabled: boolean; + inspectBrkEnabled: boolean; +} + +export async function startBackend(options: StartBackendOptions) { + // Cleaning dist/ before we start the dev process helps work around an issue + // where we end up with the entrypoint executing multiple times, causing + // a port bind conflict among other things. + await fs.remove(paths.resolveTarget('dist')); + + const waitForExit = await serveBackend({ + entry: 'src/index', + checksEnabled: options.checksEnabled, + inspectEnabled: options.inspectEnabled, + inspectBrkEnabled: options.inspectBrkEnabled, + }); + + await waitForExit(); +} diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts new file mode 100644 index 0000000000..e5500c310b --- /dev/null +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -0,0 +1,76 @@ +/* + * 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 fs from 'fs-extra'; +import chalk from 'chalk'; +import uniq from 'lodash/uniq'; +import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; +import { paths } from '../../lib/paths'; +import { Lockfile } from '../../lib/versioning'; +import { includedFilter } from '../versions/lint'; + +interface StartAppOptions { + verifyVersions?: boolean; + entry: string; + + checksEnabled: boolean; + configPaths: string[]; +} + +export async function startFrontend(options: StartAppOptions) { + if (options.verifyVersions) { + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + const result = lockfile.analyze({ + filter: includedFilter, + }); + const problemPackages = [...result.newVersions, ...result.newRanges].map( + ({ name }) => name, + ); + + if (problemPackages.length > 1) { + console.log( + chalk.yellow( + `⚠️ Some of the following packages may be outdated or have duplicate installations: + + ${uniq(problemPackages).join(', ')} + `, + ), + ); + console.log( + chalk.yellow( + `⚠️ This can be resolved using the following command: + + yarn backstage-cli versions:check --fix + `, + ), + ); + } + } + + const { name } = await fs.readJson(paths.resolveTarget('package.json')); + const waitForExit = await serveBundle({ + entry: options.entry, + checksEnabled: options.checksEnabled, + ...(await loadCliConfig({ + args: options.configPaths, + fromPackage: name, + withFilteredKeys: true, + })), + }); + + await waitForExit(); +} diff --git a/packages/cli/src/lib/monorepo/PackageGraph.ts b/packages/cli/src/lib/monorepo/PackageGraph.ts index 9860b92ef6..89d5f79cd2 100644 --- a/packages/cli/src/lib/monorepo/PackageGraph.ts +++ b/packages/cli/src/lib/monorepo/PackageGraph.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { Package } from '@manypkg/get-packages'; +import { getPackages, Package } from '@manypkg/get-packages'; +import { paths } from '../paths'; +import { PackageRole } from '../role'; type PackageJSON = Package['packageJson']; @@ -25,8 +27,17 @@ export interface ExtendedPackageJSON extends PackageJSON { // The `bundled` field is a field known within Backstage, it means // that the package bundles all of its dependencies in its build output. bundled?: boolean; + + backstage?: { + role?: PackageRole; + }; } +export type ExtendedPackage = { + dir: string; + packageJson: ExtendedPackageJSON; +}; + export type PackageGraphNode = { /** The name of the package */ name: string; @@ -47,6 +58,11 @@ export type PackageGraphNode = { }; export class PackageGraph extends Map { + static async listTargetPackages(): Promise { + const { packages } = await getPackages(paths.targetDir); + return packages as ExtendedPackage[]; + } + static fromPackages(packages: Package[]): PackageGraph { const graph = new PackageGraph(); diff --git a/packages/cli/src/lib/role/index.ts b/packages/cli/src/lib/role/index.ts new file mode 100644 index 0000000000..4e1047a628 --- /dev/null +++ b/packages/cli/src/lib/role/index.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +export type { + PackageRoleInfo, + PackagePlatform, + PackageOutputType, + PackageRole, +} from './types'; +export { + getRoleInfo, + getRoleFromPackage, + findRoleFromCommand, + detectRoleFromPackage, +} from './packageRoles'; diff --git a/packages/cli/src/lib/role/packageRoles.test.ts b/packages/cli/src/lib/role/packageRoles.test.ts new file mode 100644 index 0000000000..f6c9fb418a --- /dev/null +++ b/packages/cli/src/lib/role/packageRoles.test.ts @@ -0,0 +1,328 @@ +/* + * 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 { + getRoleInfo, + getRoleFromPackage, + findRoleFromCommand, + detectRoleFromPackage, +} from './packageRoles'; + +describe('getRoleInfo', () => { + it('provides role info by role', () => { + expect(getRoleInfo('web-library')).toEqual({ + role: 'web-library', + platform: 'web', + output: ['types', 'esm'], + }); + + expect(getRoleInfo('app')).toEqual({ + role: 'app', + platform: 'web', + output: ['bundle'], + }); + + expect(() => getRoleInfo('invalid')).toThrow( + `Unknown package role 'invalid'`, + ); + }); +}); + +describe('getRoleFromPackage', () => { + it('reads explicit package roles', () => { + expect( + getRoleFromPackage({ + backstage: { + role: 'web-library', + }, + }), + ).toEqual('web-library'); + + expect( + getRoleFromPackage({ + backstage: { + role: 'app', + }, + }), + ).toEqual('app'); + + expect(() => + getRoleFromPackage({ + name: 'test', + backstage: {}, + }), + ).toThrow('Package test must specify a role in the "backstage" field'); + + expect(() => + getRoleFromPackage({ + name: 'test', + backstage: { role: 'invalid' }, + }), + ).toThrow(`Unknown package role 'invalid'`); + }); +}); + +describe('findRoleFromCommand', () => { + function mkCommand(args: string) { + return new Command() + .option('--role ', 'test role') + .parse(['node', 'entry.js', ...args.split(' ')]) as Command; + } + + 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({ + name: 'example-app', + private: true, + bundled: true, + scripts: { + start: 'backstage-cli app:serve', + build: 'backstage-cli app:build', + clean: 'backstage-cli clean', + test: 'backstage-cli test', + 'test:e2e': + 'start-server-and-test start http://localhost:3000 cy:dev', + 'test:e2e:ci': + 'start-server-and-test start http://localhost:3000 cy:run', + lint: 'backstage-cli lint', + 'cy:dev': 'cypress open', + 'cy:run': 'cypress run', + }, + }), + ).toEqual('app'); + }); + + it('detects the role of example-backend', () => { + expect( + detectRoleFromPackage({ + name: 'example-backend', + main: 'dist/index.cjs.js', + types: 'src/index.ts', + scripts: { + build: 'backstage-cli backend:bundle', + 'build-image': + 'docker build ../.. -f Dockerfile --tag example-backend', + start: 'backstage-cli backend:dev', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + clean: 'backstage-cli clean', + 'migrate:create': 'knex migrate:make -x ts', + }, + }), + ).toEqual('backend'); + }); + + it('detects the role of @backstage/plugin-catalog', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli plugin:build', + start: 'backstage-cli plugin:serve', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + diff: 'backstage-cli plugin:diff', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-frontend'); + }); + + it('detects the role of @backstage/plugin-catalog-backend', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-backend', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + 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', + }, + }), + ).toEqual('plugin-backend'); + }); + + it('detects the role of @backstage/plugin-catalog-react', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-react', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: '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', + }, + }), + ).toEqual('web-library'); + }); + + it('detects the role of @backstage/plugin-catalog-common', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-common', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + 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 --passWithNoTests', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('common-library'); + }); + + it('detects the role of @backstage/plugin-catalog-backend-module-ldap', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-backend-module-ldap', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + types: 'dist/index.d.ts', + }, + scripts: { + 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', + }, + }), + ).toEqual('plugin-backend-module'); + }); + + it('detects the role of @backstage/plugin-permission-node', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-permission-node', + main: 'src/index.ts', + types: 'src/index.ts', + homepage: 'https://backstage.io', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + types: 'dist/index.d.ts', + }, + scripts: { + 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', + }, + }), + ).toEqual('node-library'); + }); + + it('detects the role of @backstage/plugin-analytics-module-ga', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-analytics-module-ga', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli plugin:build', + start: 'backstage-cli plugin:serve', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + diff: 'backstage-cli plugin:diff', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-frontend-module'); + }); +}); diff --git a/packages/cli/src/lib/role/packageRoles.ts b/packages/cli/src/lib/role/packageRoles.ts new file mode 100644 index 0000000000..7c855ee0fd --- /dev/null +++ b/packages/cli/src/lib/role/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 fs from 'fs-extra'; +import { Command } from 'commander'; +import { paths } from '../paths'; +import { PackageRole, PackageRoleInfo } from './types'; + +const packageRoleInfos: PackageRoleInfo[] = [ + { + role: 'app', + 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: 'plugin-frontend', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'plugin-frontend-module', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'plugin-backend', + platform: 'node', + output: ['types', 'cjs'], + }, + { + role: 'plugin-backend-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(cmd: Command): Promise { + if (cmd.role) { + return getRoleInfo(cmd.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 'app'; + } + if (pkg.scripts?.build?.includes('backend:bundle')) { + return 'backend'; + } + if (pkg.name?.includes('plugin-') && pkg.name?.includes('-backend-module-')) { + return 'plugin-backend-module'; + } + if (pkg.name?.includes('plugin-') && pkg.name?.includes('-module-')) { + return 'plugin-frontend-module'; + } + if (pkg.scripts?.start?.includes('plugin:serve')) { + return 'plugin-frontend'; + } + if (pkg.scripts?.start?.includes('backend:dev')) { + return 'plugin-backend'; + } + + 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/src/lib/role/types.ts b/packages/cli/src/lib/role/types.ts new file mode 100644 index 0000000000..6a89f7bdaf --- /dev/null +++ b/packages/cli/src/lib/role/types.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export type PackageRole = + | 'app' + | 'backend' + | 'cli' + | 'web-library' + | 'node-library' + | 'common-library' + | 'plugin-frontend' + | 'plugin-frontend-module' + | 'plugin-backend' + | 'plugin-backend-module'; + +export type PackagePlatform = 'node' | 'web' | 'common'; +export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs'; + +export interface PackageRoleInfo { + role: PackageRole; + platform: PackagePlatform; + output: PackageOutputType[]; +}