Merge pull request #9133 from backstage/rugvip/role

cli: add support for package role + script & migration utils
This commit is contained in:
Patrik Oldsberg
2022-02-04 16:32:53 +01:00
committed by GitHub
19 changed files with 1236 additions and 10 deletions
+5
View File
@@ -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.
+2 -1
View File
@@ -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",
@@ -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,
})),
});
}
@@ -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);
}
}
@@ -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<void> {
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<Output>();
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),
});
}
+17
View File
@@ -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';
+121 -8
View File
@@ -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 <path>',
'Config files to load instead of app-config.yaml',
(opt: string, opts: string[]) => [...opts, opt],
Array<string>(),
] as const;
const configOption = [
'--config <path>',
'Config files to load instead of app-config.yaml',
(opt: string, opts: string[]) => [...opts, opt],
Array<string>(),
] 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 <name>', '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 <path>',
'Config files to load instead of app-config.yaml. Applies to app packages only.',
(opt: string, opts: string[]) => [...opts, opt],
Array<string>(),
)
.action(lazy(() => import('./build').then(m => m.command)));
program
.command('lint')
.option(
'--format <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 <formats>', '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')
@@ -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,
});
}),
);
};
@@ -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<string, string | undefined> =
(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,
});
}
}),
);
}
@@ -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<void> {
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}'`,
);
}
}
+17
View File
@@ -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';
@@ -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();
}
@@ -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();
}
+17 -1
View File
@@ -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<string, PackageGraphNode> {
static async listTargetPackages(): Promise<ExtendedPackage[]> {
const { packages } = await getPackages(paths.targetDir);
return packages as ExtendedPackage[];
}
static fromPackages(packages: Package[]): PackageGraph {
const graph = new PackageGraph();
+28
View File
@@ -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';
@@ -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 <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');
});
});
+186
View File
@@ -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<PackageRole> {
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;
}
+36
View File
@@ -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[];
}