diff --git a/.changeset/migrate-cli-commands-to-cleye.md b/.changeset/migrate-cli-commands-to-cleye.md new file mode 100644 index 0000000000..5231353e50 --- /dev/null +++ b/.changeset/migrate-cli-commands-to-cleye.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Migrated remaining CLI command handlers from `commander` to `cleye` for argument parsing. diff --git a/packages/cli/src/modules/build/commands/buildWorkspace.ts b/packages/cli/src/modules/build/commands/buildWorkspace.ts index 159c347eb8..6c27396a30 100644 --- a/packages/cli/src/modules/build/commands/buildWorkspace.ts +++ b/packages/cli/src/modules/build/commands/buildWorkspace.ts @@ -15,20 +15,39 @@ */ import fs from 'fs-extra'; +import { cli } from 'cleye'; import { createDistWorkspace } from '../lib/packager'; +import type { CommandContext } from '../../../wiring/types'; -type Options = { - alwaysPack?: boolean; -}; +export default async ({ args, info }: CommandContext) => { + const { + flags: { alwaysPack }, + _: positionals, + } = cli( + { + help: info, + parameters: ['', '[packages...]'], + flags: { + alwaysPack: { + type: Boolean, + description: + 'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)', + }, + }, + }, + undefined, + args, + ); + + const [dir, ...packages] = positionals; -export default async (dir: string, packages: string[], options: Options) => { if (!(await fs.pathExists(dir))) { throw new Error(`Target workspace directory doesn't exist, '${dir}'`); } await createDistWorkspace(packages, { targetDir: dir, - alwaysPack: options.alwaysPack, + alwaysPack, enableFeatureDetection: true, }); }; diff --git a/packages/cli/src/modules/build/commands/package/build/command.ts b/packages/cli/src/modules/build/commands/package/build/command.ts index 944be832f0..8c802fe2b5 100644 --- a/packages/cli/src/modules/build/commands/package/build/command.ts +++ b/packages/cli/src/modules/build/commands/package/build/command.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import fs from 'fs-extra'; import { buildPackage, Output } from '../../../lib/builder'; import { findRoleFromCommand } from '../../../lib/role'; @@ -29,40 +29,90 @@ import { buildFrontend } from '../../../lib/buildFrontend'; import { buildBackend } from '../../../lib/buildBackend'; import { isValidUrl } from '../../../lib/urls'; import chalk from 'chalk'; +import type { CommandContext } from '../../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { + role, + minify, + skipBuildDependencies, + stats, + config, + moduleFederation, + }, + } = cli( + { + help: info, + flags: { + role: { + type: String, + description: 'Run the command with an explicit package role', + }, + minify: { + type: Boolean, + description: + 'Minify the generated code. Does not apply to app package (app is minified by default).', + }, + skipBuildDependencies: { + type: Boolean, + description: + 'Skip the automatic building of local dependencies. Applies to backend packages only.', + }, + stats: { + type: Boolean, + description: + 'If bundle stats are available, write them to the output directory. Applies to app packages only.', + }, + config: { + type: [String], + description: + 'Config files to load instead of app-config.yaml. Applies to app packages only.', + default: [], + }, + moduleFederation: { + type: Boolean, + description: + 'Build a package as a module federation remote. Applies to frontend plugin packages only.', + }, + }, + }, + undefined, + args, + ); -export async function command(opts: OptionValues): Promise { const webpack = process.env.LEGACY_WEBPACK_BUILD ? (require('webpack') as typeof import('webpack')) : undefined; - const role = await findRoleFromCommand(opts); + const resolvedRole = await findRoleFromCommand({ role }); - if (role === 'frontend' || role === 'backend') { - const configPaths = (opts.config as string[]).map(arg => { + if (resolvedRole === 'frontend' || resolvedRole === 'backend') { + const configPaths = config.map(arg => { if (isValidUrl(arg)) { return arg; } return targetPaths.resolve(arg); }); - if (role === 'frontend') { + if (resolvedRole === 'frontend') { return buildFrontend({ targetDir: targetPaths.dir, configPaths, - writeStats: Boolean(opts.stats), + writeStats: Boolean(stats), webpack, }); } return buildBackend({ targetDir: targetPaths.dir, configPaths, - skipBuildDependencies: Boolean(opts.skipBuildDependencies), - minify: Boolean(opts.minify), + skipBuildDependencies: Boolean(skipBuildDependencies), + minify: Boolean(minify), }); } let isModuleFederationRemote: boolean | undefined = undefined; - if ((role as string) === 'frontend-dynamic-container') { + if ((resolvedRole as string) === 'frontend-dynamic-container') { console.log( chalk.yellow( `⚠️ WARNING: The 'frontend-dynamic-container' package role is experimental and will receive immediate breaking changes in the future.`, @@ -70,7 +120,7 @@ export async function command(opts: OptionValues): Promise { ); isModuleFederationRemote = true; } - if (opts.moduleFederation) { + if (moduleFederation) { isModuleFederationRemote = true; } @@ -79,13 +129,13 @@ export async function command(opts: OptionValues): Promise { return buildFrontend({ targetDir: targetPaths.dir, configPaths: [], - writeStats: Boolean(opts.stats), + writeStats: Boolean(stats), isModuleFederationRemote, webpack, }); } - const roleInfo = PackageRoles.getRoleInfo(role); + const roleInfo = PackageRoles.getRoleInfo(resolvedRole); const outputs = new Set(); @@ -106,7 +156,7 @@ export async function command(opts: OptionValues): Promise { return buildPackage({ outputs, packageJson, - minify: Boolean(opts.minify), + minify: Boolean(minify), workspacePackages: await PackageGraph.listTargetPackages(), }); -} +}; diff --git a/packages/cli/src/modules/build/commands/package/build/index.ts b/packages/cli/src/modules/build/commands/package/build/index.ts index 680fe9e11d..7121eac500 100644 --- a/packages/cli/src/modules/build/commands/package/build/index.ts +++ b/packages/cli/src/modules/build/commands/package/build/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { command } from './command'; +export { default } from './command'; diff --git a/packages/cli/src/modules/build/commands/package/start/command.ts b/packages/cli/src/modules/build/commands/package/start/command.ts index 18f06ab0d2..ae85fec56e 100644 --- a/packages/cli/src/modules/build/commands/package/start/command.ts +++ b/packages/cli/src/modules/build/commands/package/start/command.ts @@ -14,22 +14,79 @@ * limitations under the License. */ -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { startPackage } from './startPackage'; import { resolveLinkedWorkspace } from './resolveLinkedWorkspace'; import { findRoleFromCommand } from '../../../lib/role'; import { targetPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { + config, + role, + check, + inspect, + inspectBrk, + require: requirePaths, + link, + entrypoint, + }, + } = cli( + { + help: info, + flags: { + config: { + type: [String], + description: 'Config files to load instead of app-config.yaml', + default: [], + }, + role: { + type: String, + description: 'Run the command with an explicit package role', + }, + check: { + type: Boolean, + description: 'Enable type checking and linting if available', + }, + inspect: { + type: String, + description: 'Enable debugger in Node.js environments', + }, + inspectBrk: { + type: String, + description: + 'Enable debugger in Node.js environments, breaking before code starts', + }, + require: { + type: [String], + description: 'Add a --require argument to the node process', + }, + link: { + type: String, + description: 'Link an external workspace for module resolution', + }, + entrypoint: { + type: String, + description: + 'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"', + }, + }, + }, + undefined, + args, + ); -export async function command(opts: OptionValues): Promise { await startPackage({ - role: await findRoleFromCommand(opts), - entrypoint: opts.entrypoint, + role: await findRoleFromCommand({ role }), + entrypoint, targetDir: targetPaths.dir, - configPaths: opts.config as string[], - checksEnabled: Boolean(opts.check), - linkedWorkspace: await resolveLinkedWorkspace(opts.link), - inspectEnabled: opts.inspect, - inspectBrkEnabled: opts.inspectBrk, - require: opts.require, + configPaths: config, + checksEnabled: Boolean(check), + linkedWorkspace: await resolveLinkedWorkspace(link), + inspectEnabled: inspect, + inspectBrkEnabled: inspectBrk, + require: requirePaths?.[0], }); -} +}; diff --git a/packages/cli/src/modules/build/commands/package/start/index.ts b/packages/cli/src/modules/build/commands/package/start/index.ts index 680fe9e11d..7121eac500 100644 --- a/packages/cli/src/modules/build/commands/package/start/index.ts +++ b/packages/cli/src/modules/build/commands/package/start/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { command } from './command'; +export { default } from './command'; diff --git a/packages/cli/src/modules/build/commands/repo/build.ts b/packages/cli/src/modules/build/commands/repo/build.ts index 59d0a839e4..b1e0239b4b 100644 --- a/packages/cli/src/modules/build/commands/repo/build.ts +++ b/packages/cli/src/modules/build/commands/repo/build.ts @@ -15,7 +15,7 @@ */ import chalk from 'chalk'; -import { Command, OptionValues } from 'commander'; +import { cli } from 'cleye'; import { relative as relativePath } from 'node:path'; import { buildPackages, getOutputsForRole } from '../../lib/builder'; import { targetPaths } from '@backstage/cli-common'; @@ -29,18 +29,46 @@ import { import { buildFrontend } from '../../lib/buildFrontend'; import { buildBackend } from '../../lib/buildBackend'; import { createScriptOptionsParser } from '../../lib/optionsParser'; +import type { CommandContext } from '../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { all, since, minify }, + } = cli( + { + help: info, + flags: { + all: { + type: Boolean, + description: + 'Build all packages, including bundled app and backend packages.', + }, + since: { + type: String, + description: + 'Only build packages and their dev dependents that changed since the specified ref', + }, + minify: { + type: Boolean, + description: + 'Minify the generated code. Does not apply to app package (app is minified by default).', + }, + }, + }, + undefined, + args, + ); -export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); const webpack = process.env.LEGACY_WEBPACK_BUILD ? (require('webpack') as typeof import('webpack')) : undefined; - if (opts.since) { + if (since) { const graph = PackageGraph.fromPackages(packages); const changedPackages = await graph.listChangedPackages({ - ref: opts.since, + ref: since, analyzeLockfile: true, }); const withDevDependents = graph.collectPackageNames( @@ -53,7 +81,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const apps = new Array(); const backends = new Array(); - const parseBuildScript = createScriptOptionsParser(cmd, ['package', 'build']); + const parseBuildScript = createScriptOptionsParser(['package', 'build'], { + role: { type: 'string' }, + minify: { type: 'boolean' }, + 'skip-build-dependencies': { type: 'boolean' }, + stats: { type: 'boolean' }, + config: { type: 'string', multiple: true }, + 'module-federation': { type: 'boolean' }, + }); const options = packages.flatMap(pkg => { const role = @@ -92,14 +127,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise { outputs, logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `, workspacePackages: packages, - minify: opts.minify ?? buildOptions.minify, + minify: minify ?? Boolean(buildOptions.minify), }; }); console.log('Building packages'); await buildPackages(options); - if (opts.all) { + if (all) { console.log('Building apps'); await runConcurrentTasks({ items: apps, @@ -112,9 +147,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise { ); return; } + const configPaths = buildOptions.config; await buildFrontend({ targetDir: pkg.dir, - configPaths: (buildOptions.config as string[]) ?? [], + configPaths: Array.isArray(configPaths) + ? (configPaths as string[]) + : [], writeStats: Boolean(buildOptions.stats), webpack, }); @@ -136,9 +174,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise { await buildBackend({ targetDir: pkg.dir, skipBuildDependencies: true, - minify: opts.minify ?? buildOptions.minify, + minify: minify ?? Boolean(buildOptions.minify), }); }, }); } -} +}; diff --git a/packages/cli/src/modules/build/commands/repo/start.ts b/packages/cli/src/modules/build/commands/repo/start.ts index f7faa46eb1..5cb5e591fb 100644 --- a/packages/cli/src/modules/build/commands/repo/start.ts +++ b/packages/cli/src/modules/build/commands/repo/start.ts @@ -21,10 +21,12 @@ import { } from '@backstage/cli-node'; import { relative as relativePath } from 'node:path'; import { targetPaths } from '@backstage/cli-common'; +import { cli } from 'cleye'; import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace'; import { startPackage } from '../package/start/startPackage'; import { parseArgs } from 'node:util'; +import type { CommandContext } from '../../../../wiring/types'; const ACCEPTED_PACKAGE_ROLES: Array = [ 'frontend', @@ -33,19 +35,60 @@ const ACCEPTED_PACKAGE_ROLES: Array = [ 'backend-plugin', ]; -type CommandOptions = { - plugin: string[]; - config: string[]; - inspect?: boolean | string; - inspectBrk?: boolean | string; - require?: string; - link?: string; -}; +export default async ({ args, info }: CommandContext) => { + const { + flags: { plugin, config, inspect, inspectBrk, require: requirePath, link }, + _: namesOrPaths, + } = cli( + { + help: info, + flags: { + plugin: { + type: [String], + description: + 'Start the dev entry-point for any matching plugin package in the repo', + default: [], + }, + config: { + type: [String], + description: 'Config files to load instead of app-config.yaml', + default: [], + }, + inspect: { + type: String, + description: + 'Enable debugger in Node.js environments. Applies to backend package only', + }, + inspectBrk: { + type: String, + description: + 'Enable debugger in Node.js environments, breaking before code starts. Applies to backend package only', + }, + require: { + type: String, + description: + 'Add a --require argument to the node process. Applies to backend package only', + }, + link: { + type: String, + description: 'Link an external workspace for module resolution', + }, + }, + }, + undefined, + args, + ); -export async function command(namesOrPaths: string[], options: CommandOptions) { - const targetPackages = await findTargetPackages(namesOrPaths, options.plugin); + const targetPackages = await findTargetPackages(namesOrPaths, plugin); - const packageOptions = await resolvePackageOptions(targetPackages, options); + const packageOptions = await resolvePackageOptions(targetPackages, { + plugin, + config, + inspect, + inspectBrk, + require: requirePath, + link, + }); if (packageOptions.length === 0) { console.log('No packages found to start'); @@ -60,7 +103,7 @@ export async function command(namesOrPaths: string[], options: CommandOptions) { // Each of these block until interrupted by user await Promise.all(packageOptions.map(entry => startPackage(entry.options))); -} +}; export async function findTargetPackages( namesOrPaths: string[], @@ -165,6 +208,15 @@ export async function findTargetPackages( ); } +type CommandOptions = { + plugin: string[]; + config: string[]; + inspect?: string; + inspectBrk?: string; + require?: string; + link?: string; +}; + async function resolvePackageOptions( targetPackages: BackstagePackage[], options: CommandOptions, diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 1e8d1d2060..139c591af2 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -14,46 +14,7 @@ * limitations under the License. */ -import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../wiring/lazy'; - -const configOption = [ - '--config ', - 'Config files to load instead of app-config.yaml', - (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), - Array(), -] as const; - -export function registerPackageCommands(command: Command) { - command - .command('build') - .description('Build a package for production deployment or publishing') - .option('--role ', 'Run the command with an explicit package role') - .option( - '--minify', - 'Minify the generated code. Does not apply to app package (app is minified by default).', - ) - .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 ? [...opts, opt] : [opt]), - Array(), - ) - .option( - '--module-federation', - 'Build a package as a module federation remote. Applies to frontend plugin packages only.', - ) - .action(lazy(() => import('./commands/package/build'), 'command')); -} export const buildPlugin = createCliPlugin({ pluginId: 'build', @@ -61,146 +22,26 @@ export const buildPlugin = createCliPlugin({ reg.addCommand({ path: ['package', 'build'], description: 'Build a package for production deployment or publishing', - execute: async ({ args }) => { - const command = new Command(); - - const defaultCommand = command - .option( - '--role ', - 'Run the command with an explicit package role', - ) - .option( - '--minify', - 'Minify the generated code. Does not apply to app package (app is minified by default).', - ) - .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 ? [...opts, opt] : [opt]), - Array(), - ) - .option( - '--module-federation', - 'Build a package as a module federation remote. Applies to frontend plugin packages only.', - ) - .action(lazy(() => import('./commands/package/build'), 'command')); - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/package/build') }, }); reg.addCommand({ path: ['repo', 'build'], description: 'Build packages in the project, excluding bundled app and backend packages.', - execute: async ({ args }) => { - const command = new Command(); - - // This command expect `package build` to be registered, as its used to parse - // individual plugins' package build scripts. - registerPackageCommands(command.command('package')); - - const defaultCommand = command - .option( - '--all', - 'Build all packages, including bundled app and backend packages.', - ) - .option( - '--since ', - 'Only build packages and their dev dependents that changed since the specified ref', - ) - .option( - '--minify', - 'Minify the generated code. Does not apply to app package (app is minified by default).', - ) - .action(lazy(() => import('./commands/repo/build'), 'command')); - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/build') }, }); reg.addCommand({ path: ['package', 'start'], description: 'Start a package for local development', - execute: async ({ args }) => { - const command = new Command(); - - const defaultCommand = command - .option(...configOption) - .option( - '--role ', - 'Run the command with an explicit package role', - ) - .option('--check', 'Enable type checking and linting if available') - .option('--inspect [host]', 'Enable debugger in Node.js environments') - .option( - '--inspect-brk [host]', - 'Enable debugger in Node.js environments, breaking before code starts', - ) - .option( - '--require ', - 'Add a --require argument to the node process', - ) - .option( - '--link ', - 'Link an external workspace for module resolution', - ) - .option( - '--entrypoint ', - 'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"', - ) - .action(lazy(() => import('./commands/package/start'), 'command')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/package/start') }, }); reg.addCommand({ path: ['repo', 'start'], description: 'Starts packages in the repo for local development', - execute: async ({ args }) => { - const command = new Command(); - - const defaultCommand = command - .argument( - '[packageNameOrPath...]', - 'Run the specified package instead of the defaults.', - ) - .option( - '--plugin ', - 'Start the dev entry-point for any matching plugin package in the repo', - (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), - Array(), - ) - .option(...configOption) - .option( - '--inspect [host]', - 'Enable debugger in Node.js environments. Applies to backend package only', - ) - .option( - '--inspect-brk [host]', - 'Enable debugger in Node.js environments, breaking before code starts. Applies to backend package only', - ) - .option( - '--require ', - 'Add a --require argument to the node process. Applies to backend package only', - ) - .option( - '--link ', - 'Link an external workspace for module resolution', - ) - .action( - lazy(() => import('../build/commands/repo/start'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/start') }, }); reg.addCommand({ @@ -239,25 +80,7 @@ export const buildPlugin = createCliPlugin({ path: ['build-workspace'], description: 'Builds a temporary dist workspace from the provided packages', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .arguments(' [packages...]') - .addOption( - new Option( - '--alwaysYarnPack', - 'Alias for --alwaysPack for backwards compatibility.', - ) - .implies({ alwaysPack: true }) - .hideHelp(true), - ) - .option( - '--alwaysPack', - 'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)', - ) - .action(lazy(() => import('./commands/buildWorkspace'), 'default')); - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/buildWorkspace') }, }); }, }); diff --git a/packages/cli/src/modules/build/lib/optionsParser.ts b/packages/cli/src/modules/build/lib/optionsParser.ts index 15561a2e42..964825830a 100644 --- a/packages/cli/src/modules/build/lib/optionsParser.ts +++ b/packages/cli/src/modules/build/lib/optionsParser.ts @@ -13,34 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Command } from 'commander'; +import { parseArgs, type ParseArgsConfig } from 'node:util'; export function createScriptOptionsParser( - anyCmd: Command, commandPath: string[], + options: ParseArgsConfig['options'], ) { - // Regardless of what command instance is passed in we want to find - // the root command and resolve the path from there - let rootCmd = anyCmd; - while (rootCmd.parent) { - rootCmd = rootCmd.parent; - } - - // Now find the command that was requested - let targetCmd = rootCmd as Command | undefined; - for (const name of commandPath) { - targetCmd = targetCmd?.commands.find(c => c.name() === name) as - | Command - | undefined; - } - - if (!targetCmd) { - throw new Error( - `Could not find package command '${commandPath.join(' ')}'`, - ); - } - const cmd = targetCmd; - const expectedScript = `backstage-cli ${commandPath.join(' ')}`; return (scriptStr?: string) => { @@ -49,22 +27,9 @@ export function createScriptOptionsParser( } const argsStr = scriptStr.slice(expectedScript.length).trim(); + const args = argsStr ? argsStr.split(/\s+/) : []; - // Can't clone or copy or even use commands as prototype, so we mutate - // the necessary members instead, and then reset them once we're done - const currentOpts = (cmd as any)._optionValues; - const currentStore = (cmd as any)._storeOptionsAsProperties; - - const result: Record = {}; - (cmd as any)._storeOptionsAsProperties = false; - (cmd as any)._optionValues = result; - - // Triggers the writing of options to the result object - cmd.parseOptions(argsStr.split(' ')); - - (cmd as any)._optionValues = currentOpts; - (cmd as any)._storeOptionsAsProperties = currentStore; - - return result; + const { values } = parseArgs({ args, strict: false, options }); + return values; }; } diff --git a/packages/cli/src/modules/build/lib/role.test.ts b/packages/cli/src/modules/build/lib/role.test.ts index dbed4310fa..b046d42432 100644 --- a/packages/cli/src/modules/build/lib/role.test.ts +++ b/packages/cli/src/modules/build/lib/role.test.ts @@ -16,20 +16,12 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; -import { Command } from 'commander'; import { findRoleFromCommand } from './role'; const mockDir = createMockDirectory(); overrideTargetPaths(mockDir.path); describe('findRoleFromCommand', () => { - function mkCommand(args?: string) { - const parsed = new Command() - .option('--role ', 'test role') - .parse(args?.split(' ') ?? [], { from: 'user' }); - return parsed.opts(); - } - beforeEach(() => { mockDir.setContent({ 'package.json': JSON.stringify({ @@ -42,16 +34,14 @@ describe('findRoleFromCommand', () => { }); it('provides role info by role', async () => { - await expect(findRoleFromCommand(mkCommand())).resolves.toEqual( - 'web-library', - ); + await expect(findRoleFromCommand({})).resolves.toEqual('web-library'); await expect( - findRoleFromCommand(mkCommand('--role node-library')), + findRoleFromCommand({ role: 'node-library' }), ).resolves.toEqual('node-library'); - await expect( - findRoleFromCommand(mkCommand('--role invalid')), - ).rejects.toThrow(`Unknown package role 'invalid'`); + await expect(findRoleFromCommand({ role: 'invalid' })).rejects.toThrow( + `Unknown package role 'invalid'`, + ); }); }); diff --git a/packages/cli/src/modules/build/lib/role.ts b/packages/cli/src/modules/build/lib/role.ts index 2f3960433b..bfcd1ecfb2 100644 --- a/packages/cli/src/modules/build/lib/role.ts +++ b/packages/cli/src/modules/build/lib/role.ts @@ -15,14 +15,13 @@ */ import fs from 'fs-extra'; -import { OptionValues } from 'commander'; import { targetPaths } from '@backstage/cli-common'; import { PackageRoles, PackageRole } from '@backstage/cli-node'; -export async function findRoleFromCommand( - opts: OptionValues, -): Promise { +export async function findRoleFromCommand(opts: { + role?: string; +}): Promise { if (opts.role) { return PackageRoles.getRoleInfo(opts.role)?.role; } diff --git a/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts b/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts index 57aca5674f..998f8eb7ae 100644 --- a/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts +++ b/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts @@ -19,14 +19,27 @@ import chalk from 'chalk'; import { stringify as stringifyYaml } from 'yaml'; import inquirer, { Question, Answers } from 'inquirer'; import { targetPaths } from '@backstage/cli-common'; +import { cli } from 'cleye'; import { GithubCreateAppServer } from './GithubCreateAppServer'; import openBrowser from 'react-dev-utils/openBrowser'; +import type { CommandContext } from '../../../../wiring/types'; // This is an experimental command that at this point does not support GitHub Enterprise // due to lacking support for creating apps from manifests. // https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest -export default async (org: string) => { +export default async ({ args, info }: CommandContext) => { + const { _: positionals } = cli( + { + help: info, + parameters: [''], + }, + undefined, + args, + ); + + const org = positionals[0]; + const answers: Answers = await inquirer.prompt({ name: 'appType', type: 'checkbox', diff --git a/packages/cli/src/modules/create-github-app/index.ts b/packages/cli/src/modules/create-github-app/index.ts index 5a3e30e870..9dad46577e 100644 --- a/packages/cli/src/modules/create-github-app/index.ts +++ b/packages/cli/src/modules/create-github-app/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'new', @@ -23,16 +21,7 @@ export default createCliPlugin({ reg.addCommand({ path: ['create-github-app'], description: 'Create new GitHub App in your organization.', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .argument('') - .action( - lazy(() => import('./commands/create-github-app'), 'default'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/create-github-app') }, }); }, }); diff --git a/packages/cli/src/modules/lint/commands/package/lint.ts b/packages/cli/src/modules/lint/commands/package/lint.ts index 0a453ac71a..7cecee10a7 100644 --- a/packages/cli/src/modules/lint/commands/package/lint.ts +++ b/packages/cli/src/modules/lint/commands/package/lint.ts @@ -15,15 +15,46 @@ */ import fs from 'fs-extra'; -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { targetPaths } from '@backstage/cli-common'; - import { ESLint } from 'eslint'; +import type { CommandContext } from '../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { fix, format, outputFile, maxWarnings }, + _: directories, + } = cli( + { + help: info, + flags: { + fix: { + type: Boolean, + description: 'Attempt to automatically fix violations', + }, + format: { + type: String, + description: 'Lint report output format', + default: 'eslint-formatter-friendly', + }, + outputFile: { + type: String, + description: 'Write the lint report to a file instead of stdout', + }, + maxWarnings: { + type: String, + description: + 'Fail if more than this number of warnings. -1 allows warnings. (default: -1)', + }, + }, + }, + undefined, + args, + ); -export default async (directories: string[], opts: OptionValues) => { const eslint = new ESLint({ cwd: targetPaths.dir, - fix: opts.fix, + fix, extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'], }); @@ -31,31 +62,31 @@ export default async (directories: string[], opts: OptionValues) => { directories.length ? directories : ['.'], ); - const maxWarnings = opts.maxWarnings ?? -1; - const ignoreWarnings = +maxWarnings === -1; + const maxWarningsNum = maxWarnings ? +maxWarnings : -1; + const ignoreWarnings = maxWarningsNum === -1; const failed = results.some(r => r.errorCount > 0) || (!ignoreWarnings && results.reduce((current, next) => current + next.warningCount, 0) > - maxWarnings); + maxWarningsNum); - if (opts.fix) { + if (fix) { await ESLint.outputFixes(results); } - const formatter = await eslint.loadFormatter(opts.format); + const formatter = await eslint.loadFormatter(format); // This formatter uses the cwd to format file paths, so let's have that happen from the root instead - if (opts.format === 'eslint-formatter-friendly') { + if (format === 'eslint-formatter-friendly') { process.chdir(targetPaths.rootDir); } const resultText = await formatter.format(results); if (resultText) { - if (opts.outputFile) { - await fs.writeFile(targetPaths.resolve(opts.outputFile), resultText); + if (outputFile) { + await fs.writeFile(targetPaths.resolve(outputFile), resultText); } else { console.log(resultText); } diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli/src/modules/lint/commands/repo/lint.ts index a3ae69302c..513c76e0a5 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli/src/modules/lint/commands/repo/lint.ts @@ -16,7 +16,7 @@ import chalk from 'chalk'; import fs from 'fs-extra'; -import { Command, OptionValues } from 'commander'; +import { cli } from 'cleye'; import { createHash } from 'node:crypto'; import { relative as relativePath } from 'node:path'; import { @@ -29,6 +29,7 @@ import { import { targetPaths } from '@backstage/cli-common'; import { createScriptOptionsParser } from '../../lib/optionsParser'; +import type { CommandContext } from '../../../../wiring/types'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; @@ -38,24 +39,77 @@ function depCount(pkg: BackstagePackageJson) { return deps + devDeps; } -export async function command(opts: OptionValues, cmd: Command): Promise { +export default async ({ args, info }: CommandContext) => { + const { + flags: { + fix, + format, + outputFile, + successCache: useSuccessCache, + successCacheDir, + since, + maxWarnings, + }, + } = cli( + { + help: info, + flags: { + fix: { + type: Boolean, + description: 'Attempt to automatically fix violations', + }, + format: { + type: String, + description: 'Lint report output format', + default: 'eslint-formatter-friendly', + }, + outputFile: { + type: String, + description: 'Write the lint report to a file instead of stdout', + }, + successCache: { + type: Boolean, + description: + 'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run', + }, + successCacheDir: { + type: String, + description: + 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', + }, + since: { + type: String, + description: + 'Only lint packages that changed since the specified ref', + }, + maxWarnings: { + type: String, + description: + 'Fail if more than this number of warnings. -1 allows warnings. (default: -1)', + }, + }, + }, + undefined, + args, + ); + let packages = await PackageGraph.listTargetPackages(); const cache = SuccessCache.create({ name: 'lint', - basePath: opts.successCacheDir, + basePath: successCacheDir, }); - const cacheContext = opts.successCache + const cacheContext = useSuccessCache ? { entries: await cache.read(), lockfile: await Lockfile.load(targetPaths.resolveRoot('yarn.lock')), } : undefined; - if (opts.since) { + if (since) { const graph = PackageGraph.fromPackages(packages); packages = await graph.listChangedPackages({ - ref: opts.since, + ref: since, analyzeLockfile: true, }); } @@ -65,7 +119,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { packages.sort((a, b) => depCount(b.packageJson) - depCount(a.packageJson)); // This formatter uses the cwd to format file paths, so let's have that happen from the root instead - if (opts.format === 'eslint-formatter-friendly') { + if (format === 'eslint-formatter-friendly') { process.chdir(targetPaths.rootDir); } @@ -74,7 +128,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise { process.env.FORCE_COLOR = '1'; } - const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']); + const parseLintScript = createScriptOptionsParser(['package', 'lint'], { + fix: { type: 'boolean' }, + format: { type: 'string' }, + 'output-file': { type: 'string' }, + 'max-warnings': { type: 'string' }, + }); const items = await Promise.all( packages.map(async pkg => { @@ -112,20 +171,20 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const { results: resultsList } = await runWorkerQueueThreads({ items: items.filter(item => item.lintOptions), // Filter out packages without lint script context: { - fix: Boolean(opts.fix), - format: opts.format as string | undefined, + fix: Boolean(fix), + format: format as string | undefined, shouldCache: Boolean(cacheContext), - maxWarnings: opts.maxWarnings ?? -1, + maxWarnings: maxWarnings ?? '-1', successCache: cacheContext?.entries, rootDir: targetPaths.rootDir, }, workerFactory: async ({ - fix, - format, + fix: workerFix, + format: workerFormat, shouldCache, successCache, rootDir, - maxWarnings, + maxWarnings: workerMaxWarnings, }) => { const { ESLint } = require('eslint') as typeof import('eslint'); const crypto = require('node:crypto') as typeof import('crypto'); @@ -151,7 +210,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const start = Date.now(); const eslint = new ESLint({ cwd: fullDir, - fix, + fix: workerFix, extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'], }); @@ -192,7 +251,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } } - const formatter = await eslint.loadFormatter(format); + const formatter = await eslint.loadFormatter(workerFormat); const results = await eslint.lintFiles(['.']); @@ -200,18 +259,18 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const time = ((Date.now() - start) / 1000).toFixed(2); console.log(`Checked ${count} files in ${relativeDir} ${time}s`); - if (fix) { + if (workerFix) { await ESLint.outputFixes(results); } - const ignoreWarnings = +maxWarnings === -1; + const ignoreWarnings = +workerMaxWarnings === -1; const resultText = formatter.format(results) as string; const failed = results.some(r => r.errorCount > 0) || (!ignoreWarnings && results.reduce((current, next) => current + next.warningCount, 0) > - maxWarnings); + +workerMaxWarnings); return { relativeDir, @@ -242,8 +301,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise { // When doing repo lint, only list the results if the lint failed to avoid a log // dump of all warnings that might be irrelevant if (resultText) { - if (opts.outputFile) { - if (opts.format === 'json') { + if (outputFile) { + if (format === 'json') { jsonResults.push(resultText); } else { errorOutput += `${resultText}\n`; @@ -258,7 +317,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } } - if (opts.format === 'json') { + if (format === 'json') { let mergedJsonResults: any[] = []; for (const jsonResult of jsonResults) { mergedJsonResults = mergedJsonResults.concat(JSON.parse(jsonResult)); @@ -266,8 +325,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise { errorOutput = JSON.stringify(mergedJsonResults, null, 2); } - if (opts.outputFile && errorOutput) { - await fs.writeFile(targetPaths.resolveRoot(opts.outputFile), errorOutput); + if (outputFile && errorOutput) { + await fs.writeFile(targetPaths.resolveRoot(outputFile), errorOutput); } if (cacheContext) { @@ -277,4 +336,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise { if (failed) { process.exit(1); } -} +}; diff --git a/packages/cli/src/modules/lint/index.ts b/packages/cli/src/modules/lint/index.ts index 40c374aa3b..8e02b7653f 100644 --- a/packages/cli/src/modules/lint/index.ts +++ b/packages/cli/src/modules/lint/index.ts @@ -14,28 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; - -export function registerPackageLintCommand(command: Command) { - command.arguments('[directories...]'); - command.option('--fix', 'Attempt to automatically fix violations'); - command.option( - '--format ', - 'Lint report output format', - 'eslint-formatter-friendly', - ); - command.option( - '--output-file ', - 'Write the lint report to a file instead of stdout', - ); - command.option( - '--max-warnings ', - 'Fail if more than this number of warnings. -1 allows warnings. (default: -1)', - ); - command.description('Lint a package'); - command.action(lazy(() => import('./commands/package/lint'), 'default')); -} export default createCliPlugin({ pluginId: 'lint', @@ -43,53 +21,13 @@ export default createCliPlugin({ reg.addCommand({ path: ['package', 'lint'], description: 'Lint a package', - execute: async ({ args }) => { - const command = new Command(); - registerPackageLintCommand(command); - - await command.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/package/lint') }, }); reg.addCommand({ path: ['repo', 'lint'], description: 'Lint a repository', - execute: async ({ args }) => { - const command = new Command(); - - registerPackageLintCommand(command.command('package').command('lint')); - - command.option('--fix', 'Attempt to automatically fix violations'); - command.option( - '--format ', - 'Lint report output format', - 'eslint-formatter-friendly', - ); - command.option( - '--output-file ', - 'Write the lint report to a file instead of stdout', - ); - command.option( - '--successCache', - 'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run', - ); - command.option( - '--successCacheDir ', - 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', - ); - command.option( - '--since ', - 'Only lint packages that changed since the specified ref', - ); - command.option( - '--max-warnings ', - 'Fail if more than this number of warnings. -1 allows warnings. (default: -1)', - ); - command.description('Lint a repository'); - command.action(lazy(() => import('./commands/repo/lint'), 'command')); - - await command.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/lint') }, }); }, }); diff --git a/packages/cli/src/modules/lint/lib/optionsParser.ts b/packages/cli/src/modules/lint/lib/optionsParser.ts index 15561a2e42..964825830a 100644 --- a/packages/cli/src/modules/lint/lib/optionsParser.ts +++ b/packages/cli/src/modules/lint/lib/optionsParser.ts @@ -13,34 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Command } from 'commander'; +import { parseArgs, type ParseArgsConfig } from 'node:util'; export function createScriptOptionsParser( - anyCmd: Command, commandPath: string[], + options: ParseArgsConfig['options'], ) { - // Regardless of what command instance is passed in we want to find - // the root command and resolve the path from there - let rootCmd = anyCmd; - while (rootCmd.parent) { - rootCmd = rootCmd.parent; - } - - // Now find the command that was requested - let targetCmd = rootCmd as Command | undefined; - for (const name of commandPath) { - targetCmd = targetCmd?.commands.find(c => c.name() === name) as - | Command - | undefined; - } - - if (!targetCmd) { - throw new Error( - `Could not find package command '${commandPath.join(' ')}'`, - ); - } - const cmd = targetCmd; - const expectedScript = `backstage-cli ${commandPath.join(' ')}`; return (scriptStr?: string) => { @@ -49,22 +27,9 @@ export function createScriptOptionsParser( } const argsStr = scriptStr.slice(expectedScript.length).trim(); + const args = argsStr ? argsStr.split(/\s+/) : []; - // Can't clone or copy or even use commands as prototype, so we mutate - // the necessary members instead, and then reset them once we're done - const currentOpts = (cmd as any)._optionValues; - const currentStore = (cmd as any)._storeOptionsAsProperties; - - const result: Record = {}; - (cmd as any)._storeOptionsAsProperties = false; - (cmd as any)._optionValues = result; - - // Triggers the writing of options to the result object - cmd.parseOptions(argsStr.split(' ')); - - (cmd as any)._optionValues = currentOpts; - (cmd as any)._storeOptionsAsProperties = currentStore; - - return result; + const { values } = parseArgs({ args, strict: false, options }); + return values; }; } diff --git a/packages/cli/src/modules/maintenance/commands/repo/fix.ts b/packages/cli/src/modules/maintenance/commands/repo/fix.ts index de09d8b490..ea367d813a 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/fix.ts +++ b/packages/cli/src/modules/maintenance/commands/repo/fix.ts @@ -21,7 +21,7 @@ import { PackageRole, PackageRoles, } from '@backstage/cli-node'; -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath, @@ -492,14 +492,38 @@ export function fixPeerModules(pkg: FixablePackage) { type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void; -export async function command(opts: OptionValues): Promise { +export default async ({ + args, + info, +}: import('../../../../wiring/types').CommandContext) => { + const { + flags: { publish, check }, + } = cli( + { + help: info, + flags: { + publish: { + type: Boolean, + description: + 'Enable additional fixes that only apply when publishing packages', + }, + check: { + type: Boolean, + description: + 'Fail if any packages would have been changed by the command', + }, + }, + }, + undefined, + args, + ); + const packages = await readFixablePackages(); const fixRepositoryField = createRepositoryFieldFixer(); const fixers: PackageFixer[] = [fixPackageExports, fixSideEffects]; - // Fixers that only apply to repos that publish packages - if (opts.publish) { + if (publish) { fixers.push( fixRepositoryField, fixPluginId, @@ -514,11 +538,11 @@ export async function command(opts: OptionValues): Promise { } } - if (opts.check) { + if (check) { if (printPackageFixHint(packages)) { process.exit(1); } } else { await writeFixedPackages(packages); } -} +}; diff --git a/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts b/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts index f4114838a1..3b081119ee 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts +++ b/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts @@ -16,12 +16,26 @@ import chalk from 'chalk'; import { ESLint } from 'eslint'; -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { relative as relativePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { json }, + } = cli( + { + help: info, + flags: { + json: { type: Boolean, description: 'Output as JSON' }, + }, + }, + undefined, + args, + ); -export async function command(opts: OptionValues) { const packages = await PackageGraph.listTargetPackages(); const eslint = new ESLint({ @@ -74,7 +88,7 @@ export async function command(opts: OptionValues) { stderr.cursorTo(0); } - if (opts.json) { + if (json) { console.log(JSON.stringify(deprecations, null, 2)); } else { for (const d of deprecations) { @@ -87,4 +101,4 @@ export async function command(opts: OptionValues) { if (deprecations.length > 0) { process.exit(1); } -} +}; diff --git a/packages/cli/src/modules/maintenance/index.ts b/packages/cli/src/modules/maintenance/index.ts index e2584527f0..a6597559f8 100644 --- a/packages/cli/src/modules/maintenance/index.ts +++ b/packages/cli/src/modules/maintenance/index.ts @@ -13,9 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Command } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'maintenance', @@ -23,36 +21,13 @@ export default createCliPlugin({ reg.addCommand({ path: ['repo', 'fix'], description: 'Automatically fix packages in the project', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .option( - '--publish', - 'Enable additional fixes that only apply when publishing packages', - ) - .option( - '--check', - 'Fail if any packages would have been changed by the command', - ) - .action(lazy(() => import('./commands/repo/fix'), 'command')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/fix') }, }); reg.addCommand({ path: ['repo', 'list-deprecations'], description: 'List deprecations', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .option('--json', 'Output as JSON') - .action( - lazy(() => import('./commands/repo/list-deprecations'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/list-deprecations') }, }); }, }); diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index c80af07c16..88e10e2607 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ import fs from 'fs-extra'; -import { Command } from 'commander'; import * as runObj from '@backstage/cli-common'; import { overrideTargetPaths } from '@backstage/cli-common/testUtils'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; +import type { CommandContext } from '../../../../wiring/types'; import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils'; import { YarnInfoInspectData } from '../../lib/versioning/packages'; import { setupServer } from 'msw/node'; @@ -126,6 +126,28 @@ const expectLogsToMatch = ( expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort()); }; +function callBump(flags: Record) { + const args: string[] = []; + for (const [key, value] of Object.entries(flags)) { + if (value === null || value === undefined) { + continue; + } + const flag = `--${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}`; + if (typeof value === 'boolean') { + if (value) { + args.push(flag); + } + } else { + args.push(flag, value); + } + } + const context: CommandContext = { + args, + info: { usage: 'backstage-cli versions:bump', description: 'test' }, + }; + return bump(context); +} + describe('bump', () => { const mockDir = createMockDirectory(); @@ -190,7 +212,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -283,11 +305,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ - pattern: null, - release: 'main', - skipInstall: true, - } as unknown as Command); + await callBump({ release: 'main', skipInstall: true }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -389,7 +407,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -493,7 +511,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -588,9 +606,9 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await expect( - bump({ pattern: null, release: '999.0.1' } as unknown as Command), - ).rejects.toThrow('No release found for 999.0.1 version'); + await expect(callBump({ release: '999.0.1' })).rejects.toThrow( + 'No release found for 999.0.1 version', + ); }); expect(logs.filter(Boolean)).toEqual([ 'Using default pattern glob @backstage/*', @@ -694,7 +712,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'next' } as unknown as Command); + await callBump({ release: 'next' }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -773,10 +791,10 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ + await callBump({ pattern: '@{backstage,backstage-extra}/*', release: 'main', - } as any); + }); }); expectLogsToMatch(logs, [ 'Using custom pattern glob @{backstage,backstage-extra}/*', @@ -882,7 +900,7 @@ describe('bump', () => { ), ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', @@ -1121,7 +1139,7 @@ describe('environment variables', () => { ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ @@ -1195,7 +1213,7 @@ describe('environment variables', () => { } as any); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ @@ -1280,7 +1298,7 @@ describe('environment variables', () => { ); const { log: logs } = await withLogCollector(['log', 'warn'], async () => { - await bump({ pattern: null, release: 'main' } as unknown as Command); + await callBump({ release: 'main' }); }); expectLogsToMatch(logs, [ @@ -1329,9 +1347,7 @@ describe('environment variables', () => { }, }); - await expect( - bump({ pattern: null, release: 'main' } as unknown as Command), - ).rejects.toThrow(); + await expect(callBump({ release: 'main' })).rejects.toThrow(); }); it('should handle network errors when using custom base URL', async () => { @@ -1359,8 +1375,6 @@ describe('environment variables', () => { ), ); - await expect( - bump({ pattern: null, release: 'main' } as unknown as Command), - ).rejects.toThrow(); + await expect(callBump({ release: 'main' })).rejects.toThrow(); }); }); diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 8fe7013ac8..a20cf1494a 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -26,7 +26,7 @@ import fs from 'fs-extra'; import chalk from 'chalk'; import { minimatch } from 'minimatch'; import semver from 'semver'; -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'node:path'; @@ -48,6 +48,7 @@ import { import { migrateMovedPackages } from './migrate'; import { runYarnInstall } from '../../lib/utils'; import { run } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; const DEP_TYPES = [ 'dependencies', @@ -73,12 +74,41 @@ function extendsDefaultPattern(pattern: string): boolean { return minimatch('@backstage/', pattern.slice(0, -1)); } -export default async (opts: OptionValues) => { +export default async ({ args, info }: CommandContext) => { + const { + flags: { pattern: patternFlag, release, skipInstall, skipMigrate }, + } = cli( + { + help: info, + flags: { + pattern: { + type: String, + description: 'Override glob for matching packages to upgrade', + }, + release: { + type: String, + description: 'Bump to a specific Backstage release line or version', + default: 'main', + }, + skipInstall: { + type: Boolean, + description: 'Skips yarn install step', + }, + skipMigrate: { + type: Boolean, + description: 'Skips migration of any moved packages', + }, + }, + }, + undefined, + args, + ); + const lockfilePath = targetPaths.resolveRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); const yarnPluginEnabled = await hasBackstageYarnPlugin(); - let pattern = opts.pattern; + let pattern = patternFlag; if (!pattern) { console.log(`Using default pattern glob ${DEFAULT_PATTERN_GLOB}`); @@ -97,15 +127,15 @@ export default async (opts: OptionValues) => { findTargetVersion = createStrictVersionFinder({ releaseManifest, }); - } else if (semver.valid(opts.release)) { + } else if (semver.valid(release)) { // Specific release specified. Be strict when resolving versions - releaseManifest = await getManifestByVersion({ version: opts.release }); + releaseManifest = await getManifestByVersion({ version: release! }); findTargetVersion = createStrictVersionFinder({ releaseManifest, }); } else { // Release line specified. Be lenient when resolving versions. - if (opts.release === 'next') { + if (release === 'next') { const next = await getManifestByReleaseLine({ releaseLine: 'next', versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, @@ -120,12 +150,12 @@ export default async (opts: OptionValues) => { : main; } else { releaseManifest = await getManifestByReleaseLine({ - releaseLine: opts.release, + releaseLine: release!, versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, }); } findTargetVersion = createVersionFinder({ - releaseLine: opts.releaseLine, + releaseLine: release, releaseManifest, }); } @@ -264,7 +294,7 @@ export default async (opts: OptionValues) => { ); } - if (!opts.skipInstall) { + if (!skipInstall) { await runYarnInstall(); } else { console.log(); @@ -272,14 +302,14 @@ export default async (opts: OptionValues) => { console.log(chalk.yellow(`Skipping yarn install`)); } - if (!opts.skipMigrate) { + if (!skipMigrate) { console.log(); const changed = await migrateMovedPackages({ - pattern: opts.pattern, + pattern: patternFlag, }); - if (changed && !opts.skipInstall) { + if (changed && !skipInstall) { await runYarnInstall(); } } diff --git a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts b/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts index 6748f8c1ff..fc50a5c491 100644 --- a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts @@ -123,7 +123,7 @@ describe('versions:migrate', () => { }); const { warn, log: logs } = await withLogCollector(async () => { - await migrate({}); + await migrate({ args: [], info: { usage: 'test', description: 'test' } }); }); expectLogsToMatch(logs, [ @@ -229,7 +229,7 @@ describe('versions:migrate', () => { }); await withLogCollector(async () => { - await migrate({}); + await migrate({ args: [], info: { usage: 'test', description: 'test' } }); }); expect(runObj.run).toHaveBeenCalledTimes(1); @@ -311,7 +311,7 @@ describe('versions:migrate', () => { }); await withLogCollector(async () => { - await migrate({}); + await migrate({ args: [], info: { usage: 'test', description: 'test' } }); }); expect(runObj.run).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/modules/migrate/commands/versions/migrate.ts b/packages/cli/src/modules/migrate/commands/versions/migrate.ts index e5d4f1b2d3..4db800641a 100644 --- a/packages/cli/src/modules/migrate/commands/versions/migrate.ts +++ b/packages/cli/src/modules/migrate/commands/versions/migrate.ts @@ -16,11 +16,12 @@ import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; import chalk from 'chalk'; import { resolve as resolvePath, join as joinPath } from 'node:path'; -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { readJson, writeJson } from 'fs-extra'; import { minimatch } from 'minimatch'; import { runYarnInstall } from '../../lib/utils'; import replace from 'replace-in-file'; +import type { CommandContext } from '../../../../wiring/types'; declare module 'replace-in-file' { export default function (config: { @@ -38,10 +39,30 @@ declare module 'replace-in-file' { >; } -export default async (options: OptionValues) => { +export default async ({ args, info }: CommandContext) => { + const { + flags: { pattern, skipCodeChanges }, + } = cli( + { + help: info, + flags: { + pattern: { + type: String, + description: 'Override glob for matching packages to upgrade', + }, + skipCodeChanges: { + type: Boolean, + description: 'Skip code changes and only update package.json files', + }, + }, + }, + undefined, + args, + ); + const changed = await migrateMovedPackages({ - pattern: options.pattern, - skipCodeChanges: options.skipCodeChanges, + pattern, + skipCodeChanges, }); if (changed) { diff --git a/packages/cli/src/modules/migrate/index.ts b/packages/cli/src/modules/migrate/index.ts index 2dc4ed452d..c731479a51 100644 --- a/packages/cli/src/modules/migrate/index.ts +++ b/packages/cli/src/modules/migrate/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'migrate', @@ -24,45 +22,13 @@ export default createCliPlugin({ path: ['versions:migrate'], description: 'Migrate any plugins that have been moved to the @backstage-community namespace automatically', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .option( - '--pattern ', - 'Override glob for matching packages to upgrade', - ) - .option( - '--skip-code-changes', - 'Skip code changes and only update package.json files', - ) - .action(lazy(() => import('./commands/versions/migrate'), 'default')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/versions/migrate') }, }); reg.addCommand({ path: ['versions:bump'], description: 'Bump Backstage packages to the latest versions', - execute: async ({ args }) => { - const command = new Command(); - - const defaultCommand = command - .option( - '--pattern ', - 'Override glob for matching packages to upgrade', - ) - .option( - '--release ', - 'Bump to a specific Backstage release line or version', - 'main', - ) - .option('--skip-install', 'Skips yarn install step') - .option('--skip-migrate', 'Skips migration of any moved packages') - .action(lazy(() => import('./commands/versions/bump'), 'default')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/versions/bump') }, }); reg.addCommand({ diff --git a/packages/cli/src/modules/new/commands/new.test.ts b/packages/cli/src/modules/new/commands/new.test.ts index 2183d80062..0da42e7019 100644 --- a/packages/cli/src/modules/new/commands/new.test.ts +++ b/packages/cli/src/modules/new/commands/new.test.ts @@ -16,6 +16,7 @@ import { createNewPackage } from '../lib/createNewPackage'; import { default as newCommand } from './new'; +import type { CommandContext } from '../../../wiring/types'; jest.mock('../lib/createNewPackage'); @@ -34,13 +35,21 @@ describe.each([ }); it(`should generate naming options for --scope=${scope}`, async () => { - await newCommand({ scope, option: [], skipInstall: false }); + const args = ['--skip-install']; + if (scope) { + args.push('--scope', scope); + } + const context: CommandContext = { + args, + info: { usage: 'backstage-cli new', description: 'test' }, + }; + await newCommand(context); expect(createNewPackage).toHaveBeenCalledWith( expect.objectContaining({ - configOverrides: { + configOverrides: expect.objectContaining({ packageNamePrefix: prefix, packageNamePluginInfix: infix, - }, + }), }), ); }); diff --git a/packages/cli/src/modules/new/commands/new.ts b/packages/cli/src/modules/new/commands/new.ts index a38316dd62..e5c674c50c 100644 --- a/packages/cli/src/modules/new/commands/new.ts +++ b/packages/cli/src/modules/new/commands/new.ts @@ -14,28 +14,67 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { createNewPackage } from '../lib/createNewPackage'; +import type { CommandContext } from '../../../wiring/types'; -type ArgOptions = { - option: string[]; - select?: string; - skipInstall: boolean; - private?: boolean; - npmRegistry?: string; - scope?: string; - license?: string; - baseVersion?: string; -}; - -export default async (opts: ArgOptions) => { +export default async ({ args, info }: CommandContext) => { const { - option: rawArgOptions, - select: preselectedTemplateId, - skipInstall, - scope, - private: isPrivate, - ...otherGlobals - } = opts; + flags: { + select, + option: rawArgOptions, + skipInstall, + scope, + npmRegistry, + baseVersion, + license, + private: isPrivate, + }, + } = cli( + { + help: info, + flags: { + select: { + type: String, + description: 'Select the thing you want to be creating upfront', + }, + option: { + type: [String], + description: 'Pre-fill options for the creation process', + default: [], + }, + skipInstall: { + type: Boolean, + description: `Skips running 'yarn install' and 'yarn lint --fix'`, + }, + scope: { + type: String, + description: 'The scope to use for new packages', + }, + npmRegistry: { + type: String, + description: 'The package registry to use for new packages', + }, + baseVersion: { + type: String, + description: + 'The version to use for any new packages (default: 0.1.0)', + }, + license: { + type: String, + description: + 'The license to use for any new packages (default: Apache-2.0)', + }, + private: { + type: Boolean, + description: 'Mark new packages as private', + default: true, + }, + }, + }, + undefined, + args, + ); const prefilledParams = parseParams(rawArgOptions); @@ -50,8 +89,8 @@ export default async (opts: ArgOptions) => { } if ( - isPrivate === false || // set to false with --no-private flag - Object.values(otherGlobals).filter(Boolean).length !== 0 + isPrivate === false || + [npmRegistry, baseVersion, license].filter(Boolean).length !== 0 ) { console.warn( `Global template configuration via CLI flags is deprecated, see https://backstage.io/docs/cli/new for information on how to configure package templating`, @@ -60,16 +99,16 @@ export default async (opts: ArgOptions) => { await createNewPackage({ prefilledParams, - preselectedTemplateId, + preselectedTemplateId: select, configOverrides: { - license: otherGlobals.license, - version: otherGlobals.baseVersion, + license, + version: baseVersion, private: isPrivate, - publishRegistry: otherGlobals.npmRegistry, + publishRegistry: npmRegistry, packageNamePrefix: packagePrefix, packageNamePluginInfix: pluginInfix, }, - skipInstall, + skipInstall: Boolean(skipInstall), }); }; diff --git a/packages/cli/src/modules/new/index.ts b/packages/cli/src/modules/new/index.ts index ac21b825fe..5b2a6562ea 100644 --- a/packages/cli/src/modules/new/index.ts +++ b/packages/cli/src/modules/new/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; import { NotImplementedError } from '@backstage/errors'; export default createCliPlugin({ @@ -25,45 +23,7 @@ export default createCliPlugin({ path: ['new'], description: 'Open up an interactive guide to creating new things in your app', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .storeOptionsAsProperties(false) - .description( - 'Open up an interactive guide to creating new things in your app', - ) - .option( - '--select ', - 'Select the thing you want to be creating upfront', - ) - .option( - '--option =', - 'Pre-fill options for the creation process', - (opt, arr: string[]) => [...arr, opt], - [], - ) - .option( - '--skip-install', - `Skips running 'yarn install' and 'yarn lint --fix'`, - ) - .option('--scope ', 'The scope to use for new packages') - .option( - '--npm-registry ', - 'The package registry to use for new packages', - ) - .option( - '--baseVersion ', - 'The version to use for any new packages (default: 0.1.0)', - ) - .option( - '--license ', - 'The license to use for any new packages (default: Apache-2.0)', - ) - .option('--no-private', 'Do not mark new packages as private') - .action(lazy(() => import('./commands/new'), 'default')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/new') }, }); reg.addCommand({ diff --git a/packages/cli/src/modules/test/commands/package/test.ts b/packages/cli/src/modules/test/commands/package/test.ts index 9c13019bb5..687b66dbfd 100644 --- a/packages/cli/src/modules/test/commands/package/test.ts +++ b/packages/cli/src/modules/test/commands/package/test.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { Command, OptionValues } from 'commander'; - import { runCheck, findOwnPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; function includesAnyOf(hayStack: string[], ...needles: string[]) { for (const needle of needles) { @@ -27,15 +26,7 @@ function includesAnyOf(hayStack: string[], ...needles: string[]) { return false; } -export default async (_opts: OptionValues, cmd: Command) => { - // all args are forwarded to jest - let parent = cmd; - while (parent.parent) { - parent = parent.parent; - } - const allArgs = parent.args as string[]; - const args = allArgs.slice(allArgs.indexOf('test') + 1); - +export default async ({ args }: CommandContext) => { // Only include our config if caller isn't passing their own config if (!includesAnyOf(args, '-c', '--config')) { /* eslint-disable-next-line no-restricted-syntax */ diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index bb614798b4..ececc5940c 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -16,12 +16,12 @@ import os from 'node:os'; import crypto from 'node:crypto'; +import { parseArgs } from 'node:util'; import yargs from 'yargs'; // 'jest-cli' is included with jest and should be kept in sync with the installed jest version // eslint-disable-next-line @backstage/no-undeclared-imports import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli'; import { relative as relativePath } from 'node:path'; -import { Command, OptionValues } from 'commander'; import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node'; import { @@ -31,6 +31,7 @@ import { findOwnPaths, isChildPath, } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; type JestProject = { displayName: string; @@ -148,18 +149,23 @@ function removeOptionArg(args: string[], option: string, size: number = 2) { } while (changed); } -export async function command(opts: OptionValues, cmd: Command): Promise { +export default async ({ args }: CommandContext) => { const testGlobal = global as TestGlobal; - // all args are forwarded to jest - let parent = cmd; - while (parent.parent) { - parent = parent.parent; - } - const allArgs = parent.args as string[]; - const args = allArgs.slice(allArgs.indexOf('test') + 1); + // Parse our own flags from the raw args using strict: false to allow Jest flags through + const { values: opts } = parseArgs({ + args, + strict: false, + options: { + since: { type: 'string' }, + successCache: { type: 'boolean' }, + successCacheDir: { type: 'string' }, + 'jest-help': { type: 'boolean' }, + }, + }); const hasFlags = createFlagFinder(args); + const sinceRef = typeof opts.since === 'string' ? opts.since : undefined; // Parse the args to ensure that no file filters are provided, in which case we refuse to run const { _: parsedArgs } = await yargs(args).options(jestYargsOptions).argv; @@ -177,7 +183,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { // Run in watch mode unless in CI, coverage mode, or running all tests let isSingleWatchMode = args.includes('--watch'); if ( - !opts.since && + !sinceRef && !process.env.CI && !hasFlags('--coverage', '--watch', '--watchAll') ) { @@ -261,10 +267,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } let selectedProjects: string[] | undefined = undefined; - if (opts.since && !hasFlags('--selectProjects')) { + if (sinceRef && !hasFlags('--selectProjects')) { const graph = await getPackageGraph(); const changedPackages = await graph.listChangedPackages({ - ref: opts.since, + ref: sinceRef, analyzeLockfile: true, }); @@ -304,7 +310,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { }--no-node-snapshot`; } - if (args.includes('--jest-help')) { + if (opts['jest-help']) { removeOptionArg(args, '--jest-help'); args.push('--help'); } @@ -332,7 +338,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const cache = SuccessCache.create({ name: 'test', - basePath: opts.successCacheDir, + basePath: opts.successCacheDir as string | undefined, }); const graph = await getPackageGraph(); @@ -439,4 +445,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } await runJest(args); -} +}; diff --git a/packages/cli/src/modules/test/index.ts b/packages/cli/src/modules/test/index.ts index f003d9c054..627648332b 100644 --- a/packages/cli/src/modules/test/index.ts +++ b/packages/cli/src/modules/test/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'test', @@ -24,43 +22,14 @@ export default createCliPlugin({ path: ['repo', 'test'], description: 'Run tests, forwarding args to Jest, defaulting to watch mode', - execute: async ({ args }) => { - const command = new Command(); - command.allowUnknownOption(true); - command.allowExcessArguments(true); - command.option( - '--since ', - 'Only test packages that changed since the specified ref', - ); - command.option('--successCache', 'Enable success caching'); - command.option( - '--successCacheDir ', - 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', - ); - command.option( - '--jest-help', - 'Show help for Jest CLI options, which are passed through', - ); - command.action(lazy(() => import('./commands/repo/test'), 'command')); - await command.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/repo/test') }, }); reg.addCommand({ path: ['package', 'test'], description: 'Run tests, forwarding args to Jest, defaulting to watch mode', - execute: async ({ args }) => { - const command = new Command(); - - command.allowUnknownOption(true); - command.allowExcessArguments(true); - command.helpOption('--backstage-cli-help'); - command.action( - lazy(() => import('./commands/package/test'), 'default'), - ); - await command.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/package/test') }, }); }, });