diff --git a/.changeset/cli-execute-loader.md b/.changeset/cli-execute-loader.md new file mode 100644 index 0000000000..4636468864 --- /dev/null +++ b/.changeset/cli-execute-loader.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added support for lazy loading of CLI command implementations through a new loader pattern for `BackstageCommand.execute`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 9a4e690e92..17d2189beb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -81,6 +81,7 @@ "buffer": "^6.0.3", "chalk": "^4.0.0", "chokidar": "^3.3.1", + "cleye": "^2.2.1", "commander": "^14.0.3", "cross-fetch": "^4.0.0", "cross-spawn": "^7.0.3", diff --git a/packages/cli/src/modules/build/commands/repo/clean.ts b/packages/cli/src/modules/build/commands/repo/clean.ts index 28b123fa9e..a3c6e9cbbd 100644 --- a/packages/cli/src/modules/build/commands/repo/clean.ts +++ b/packages/cli/src/modules/build/commands/repo/clean.ts @@ -20,7 +20,7 @@ import { PackageGraph } from '@backstage/cli-node'; import { run, targetPaths } from '@backstage/cli-common'; -export async function command(): Promise { +export default async function command(): Promise { const packages = await PackageGraph.listTargetPackages(); await fs.remove(targetPaths.resolveRoot('dist')); diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 30a9b3a19e..7a116f5d8a 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -206,52 +206,34 @@ export const buildPlugin = createCliPlugin({ reg.addCommand({ path: ['package', 'clean'], description: 'Delete cache directories', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/clean'), 'default'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/package/clean'), }, }); reg.addCommand({ path: ['package', 'prepack'], description: 'Prepares a package for packaging before publishing', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/pack'), 'pre'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: async () => { + const { pre } = await import('./commands/package/pack'); + await pre(); }, }); reg.addCommand({ path: ['package', 'postpack'], description: 'Restores the changes made by the prepack command', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/package/pack'), 'post'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: async () => { + const { post } = await import('./commands/package/pack'); + await post(); }, }); reg.addCommand({ path: ['repo', 'clean'], description: 'Delete cache and output directories', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/repo/clean'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/repo/clean'), }, }); diff --git a/packages/cli/src/modules/config/commands/docs.ts b/packages/cli/src/modules/config/commands/docs.ts index e7d3ac13ee..ed949c695f 100644 --- a/packages/cli/src/modules/config/commands/docs.ts +++ b/packages/cli/src/modules/config/commands/docs.ts @@ -14,20 +14,38 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { JsonObject } from '@backstage/types'; import { mergeConfigSchemas } from '@backstage/config-loader'; -import { OptionValues } from 'commander'; import { JSONSchema7 as JSONSchema } from 'json-schema'; import openBrowser from 'react-dev-utils/openBrowser'; import chalk from 'chalk'; import { loadCliConfig } from '../lib/config'; +import type { CommandContext } from '../../../wiring/types'; const DOCS_URL = 'https://config.backstage.io'; -export default async (opts: OptionValues) => { +export default async ({ args, info }: CommandContext) => { + const { + flags: { package: pkg }, + } = cli( + { + help: info, + flags: { + package: { + type: String, + description: + 'Only include the schema that applies to the given package', + }, + }, + }, + undefined, + args, + ); + const { schema: appSchemas } = await loadCliConfig({ args: [], - fromPackage: opts.package, + fromPackage: pkg, mockEnv: true, }); diff --git a/packages/cli/src/modules/config/commands/print.ts b/packages/cli/src/modules/config/commands/print.ts index 98833a7946..412fdd69ff 100644 --- a/packages/cli/src/modules/config/commands/print.ts +++ b/packages/cli/src/modules/config/commands/print.ts @@ -14,36 +14,67 @@ * limitations under the License. */ -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { stringify as stringifyYaml } from 'yaml'; import { AppConfig, ConfigReader } from '@backstage/config'; import { loadCliConfig } from '../lib/config'; import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader'; +import type { CommandContext } from '../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { config, lax, frontend, withSecrets, format, package: pkg }, + } = cli( + { + help: info, + flags: { + package: { type: String, description: 'Package to print config for' }, + lax: { + type: Boolean, + description: 'Do not require environment variables to be set', + }, + frontend: { type: Boolean, description: 'Only print frontend config' }, + withSecrets: { + type: Boolean, + description: 'Include secrets in the output', + }, + format: { type: String, description: 'Output format (yaml or json)' }, + config: { + type: [String], + description: 'Config files to load instead of app-config.yaml', + }, + }, + }, + undefined, + args, + ); -export default async (opts: OptionValues) => { const { schema, appConfigs } = await loadCliConfig({ - args: opts.config, - fromPackage: opts.package, - mockEnv: opts.lax, - fullVisibility: !opts.frontend, + args: config, + fromPackage: pkg, + mockEnv: lax, + fullVisibility: !frontend, }); - const visibility = getVisibilityOption(opts); + const visibility = getVisibilityOption(frontend, withSecrets); const data = serializeConfigData(appConfigs, schema, visibility); - if (opts.format === 'json') { + if (format === 'json') { process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); } else { process.stdout.write(`${stringifyYaml(data)}\n`); } }; -function getVisibilityOption(opts: OptionValues): ConfigVisibility { - if (opts.frontend && opts.withSecrets) { +function getVisibilityOption( + frontend: boolean | undefined, + withSecrets: boolean | undefined, +): ConfigVisibility { + if (frontend && withSecrets) { throw new Error('Not allowed to combine frontend and secret config'); } - if (opts.frontend) { + if (frontend) { return 'frontend'; - } else if (opts.withSecrets) { + } else if (withSecrets) { return 'secret'; } return 'backend'; diff --git a/packages/cli/src/modules/config/commands/schema.ts b/packages/cli/src/modules/config/commands/schema.ts index 73eb487d60..b41264e4b7 100644 --- a/packages/cli/src/modules/config/commands/schema.ts +++ b/packages/cli/src/modules/config/commands/schema.ts @@ -14,22 +14,41 @@ * limitations under the License. */ -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { JSONSchema7 as JSONSchema } from 'json-schema'; import { stringify as stringifyYaml } from 'yaml'; import { loadCliConfig } from '../lib/config'; import { JsonObject } from '@backstage/types'; import { mergeConfigSchemas } from '@backstage/config-loader'; +import type { CommandContext } from '../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { merge, format, package: pkg }, + } = cli( + { + help: info, + flags: { + package: { type: String, description: 'Package to print schema for' }, + format: { type: String, description: 'Output format (yaml or json)' }, + merge: { + type: Boolean, + description: 'Merge all schemas into a single schema', + }, + }, + }, + undefined, + args, + ); -export default async (opts: OptionValues) => { const { schema } = await loadCliConfig({ args: [], - fromPackage: opts.package, + fromPackage: pkg, mockEnv: true, }); let configSchema: JsonObject | JSONSchema; - if (opts.merge) { + if (merge) { configSchema = mergeConfigSchemas( (schema.serialize().schemas as JsonObject[]).map( _ => _.value as JSONSchema, @@ -42,7 +61,7 @@ export default async (opts: OptionValues) => { configSchema = schema.serialize(); } - if (opts.format === 'json') { + if (format === 'json') { process.stdout.write(`${JSON.stringify(configSchema, null, 2)}\n`); } else { process.stdout.write(`${stringifyYaml(configSchema)}\n`); diff --git a/packages/cli/src/modules/config/commands/validate.ts b/packages/cli/src/modules/config/commands/validate.ts index ac0b9d15e0..abbbd7a176 100644 --- a/packages/cli/src/modules/config/commands/validate.ts +++ b/packages/cli/src/modules/config/commands/validate.ts @@ -14,16 +14,47 @@ * limitations under the License. */ -import { OptionValues } from 'commander'; +import { cli } from 'cleye'; import { loadCliConfig } from '../lib/config'; +import type { CommandContext } from '../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { config, lax, frontend, deprecated, strict, package: pkg }, + } = cli( + { + help: info, + flags: { + package: { + type: String, + description: 'Package to validate config for', + }, + lax: { + type: Boolean, + description: 'Do not require environment variables to be set', + }, + frontend: { + type: Boolean, + description: 'Only validate frontend config', + }, + deprecated: { type: Boolean, description: 'Output deprecated keys' }, + strict: { type: Boolean, description: 'Enable strict validation' }, + config: { + type: [String], + description: 'Config files to load instead of app-config.yaml', + }, + }, + }, + undefined, + args, + ); -export default async (opts: OptionValues) => { await loadCliConfig({ - args: opts.config, - fromPackage: opts.package, - mockEnv: opts.lax, - fullVisibility: !opts.frontend, - withDeprecatedKeys: opts.deprecated, - strict: opts.strict, + args: config, + fromPackage: pkg, + mockEnv: lax, + fullVisibility: !frontend, + withDeprecatedKeys: deprecated, + strict, }); }; diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli/src/modules/config/index.ts index 234e62fde0..0d90037f18 100644 --- a/packages/cli/src/modules/config/index.ts +++ b/packages/cli/src/modules/config/index.ts @@ -14,9 +14,6 @@ * limitations under the License. */ import { createCliPlugin } from '../../wiring/factory'; -import yargs from 'yargs'; -import { Command } from 'commander'; -import { lazy } from '../../wiring/lazy'; export const configOption = [ '--config ', @@ -31,108 +28,33 @@ export default createCliPlugin({ reg.addCommand({ path: ['config:docs'], description: 'Browse the configuration reference documentation', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command - .option( - '--package ', - 'Only include the schema that applies to the given package', - ) - .description('Browse the configuration reference documentation') - .action(lazy(() => import('./commands/docs'), 'default')); - - await defaultCommand.parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/docs') }, }); reg.addCommand({ path: ['config', 'docs'], description: 'Browse the configuration reference documentation', - execute: async ({ args, info }) => { - await new Command(info.usage) - .option( - '--package ', - 'Only include the schema that applies to the given package', - ) - .description(info.description) - .action(lazy(() => import('./commands/docs'), 'default')) - .parseAsync(args, { from: 'user' }); - }, + execute: { loader: () => import('./commands/docs') }, }); reg.addCommand({ path: ['config:print'], description: 'Print the app configuration for the current package', - execute: async ({ args, info }) => { - const argv = await yargs() - .options({ - package: { type: 'string' }, - lax: { type: 'boolean' }, - frontend: { type: 'boolean' }, - 'with-secrets': { type: 'boolean' }, - format: { type: 'string' }, - config: { type: 'string', array: true, default: [] }, - }) - .usage('$0', info.description) - .help() - .parse(args); - await lazy(() => import('./commands/print'), 'default')(argv); - }, + execute: { loader: () => import('./commands/print') }, }); reg.addCommand({ path: ['config:check'], description: 'Validate that the given configuration loads and matches schema', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - package: { type: 'string' }, - lax: { type: 'boolean' }, - frontend: { type: 'boolean' }, - deprecated: { type: 'boolean' }, - strict: { type: 'boolean' }, - config: { - type: 'string', - array: true, - default: [], - }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/validate'), 'default')(argv); - }, + execute: { loader: () => import('./commands/validate') }, }); - reg.addCommand({ path: ['config:schema'], description: 'Print the JSON schema for the given configuration', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - package: { type: 'string' }, - format: { type: 'string' }, - merge: { type: 'boolean' }, - 'no-merge': { type: 'boolean' }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/schema'), 'default')(argv); - }, + execute: { loader: () => import('./commands/schema') }, }); - reg.addCommand({ path: ['config', 'schema'], description: 'Print the JSON schema for the given configuration', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - package: { type: 'string' }, - format: { type: 'string' }, - merge: { type: 'boolean' }, - 'no-merge': { type: 'boolean' }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/schema'), 'default')(argv); - }, + execute: { loader: () => import('./commands/schema') }, }); }, }); diff --git a/packages/cli/src/modules/info/commands/info.ts b/packages/cli/src/modules/info/commands/info.ts index c6732e2af1..c423d2b1dc 100644 --- a/packages/cli/src/modules/info/commands/info.ts +++ b/packages/cli/src/modules/info/commands/info.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { version as cliVersion } from '../../../../package.json'; import os from 'node:os'; import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common'; @@ -24,11 +25,7 @@ import { } from '@backstage/cli-node'; import { minimatch } from 'minimatch'; import fs from 'fs-extra'; - -interface InfoOptions { - include: string[]; - format: 'text' | 'json'; -} +import type { CommandContext } from '../../../wiring/types'; /** * Attempts to read package.json from node_modules for a given package name. @@ -55,7 +52,31 @@ function hasBackstageField(packageName: string, targetPath: string): boolean { return pkg?.backstage !== undefined; } -export default async (options: InfoOptions) => { +export default async ({ args, info }: CommandContext) => { + const { + flags: { include, format }, + } = cli( + { + help: info, + flags: { + include: { + type: [String], + description: + 'Glob patterns for additional packages to include (e.g., @spotify/backstage*)', + }, + format: { + type: String, + description: 'Output format (text or json)', + default: 'text', + }, + }, + }, + undefined, + args, + ); + + const options = { include, format: format as 'text' | 'json' }; + await new Promise(async () => { const yarnVersion = await runOutput(['yarn', '--version']); /* eslint-disable-next-line no-restricted-syntax */ diff --git a/packages/cli/src/modules/info/index.ts b/packages/cli/src/modules/info/index.ts index 0919c2ce07..8e1fe9cbff 100644 --- a/packages/cli/src/modules/info/index.ts +++ b/packages/cli/src/modules/info/index.ts @@ -13,9 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../wiring/lazy'; export default createCliPlugin({ pluginId: 'info', @@ -23,27 +21,7 @@ export default createCliPlugin({ reg.addCommand({ path: ['info'], description: 'Show helpful information for debugging and reporting bugs', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - include: { - type: 'string', - array: true, - default: [], - description: - 'Glob patterns for additional packages to include (e.g., @spotify/backstage*)', - }, - format: { - type: 'string', - choices: ['text', 'json'], - default: 'text', - description: 'Output format (text or json)', - }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/info'), 'default')(argv); - }, + execute: { loader: () => import('./commands/info') }, }); }, }); diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli/src/modules/migrate/commands/packageExports.ts index ea42638e58..581c334c6f 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli/src/modules/migrate/commands/packageExports.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export async function command() { +export default async function command() { throw new Error( 'The `migrate package-exports` command has been removed, use `repo fix` instead.', ); diff --git a/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts b/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts index d6e6183b83..4df76a51ac 100644 --- a/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts +++ b/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts @@ -21,7 +21,7 @@ import { runOutput } from '@backstage/cli-common'; const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`; -export async function command() { +export default async function command() { const packages = await PackageGraph.listTargetPackages(); const oldConfigs = [ diff --git a/packages/cli/src/modules/migrate/commands/packageScripts.ts b/packages/cli/src/modules/migrate/commands/packageScripts.ts index 6c4e41c3ec..f875bf14fc 100644 --- a/packages/cli/src/modules/migrate/commands/packageScripts.ts +++ b/packages/cli/src/modules/migrate/commands/packageScripts.ts @@ -22,7 +22,7 @@ const configArgPattern = /--config[=\s][^\s$]+/; const noStartRoles: PackageRole[] = ['cli', 'common-library']; -export async function command() { +export default async function command() { const packages = await PackageGraph.listTargetPackages(); await Promise.all( diff --git a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts b/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts index 3f187dd534..3c2cf46560 100644 --- a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts +++ b/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts @@ -21,7 +21,7 @@ import { PackageGraph, PackageRoles } from '@backstage/cli-node'; const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom']; const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0'; -export async function command() { +export default async function command() { const packages = await PackageGraph.listTargetPackages(); await Promise.all( diff --git a/packages/cli/src/modules/migrate/index.ts b/packages/cli/src/modules/migrate/index.ts index d94d04ed8d..2dc4ed452d 100644 --- a/packages/cli/src/modules/migrate/index.ts +++ b/packages/cli/src/modules/migrate/index.ts @@ -68,39 +68,24 @@ export default createCliPlugin({ reg.addCommand({ path: ['migrate', 'package-roles'], description: `Add package role field to packages that don't have it`, - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/packageRole'), 'default'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/packageRole'), }, }); reg.addCommand({ path: ['migrate', 'package-scripts'], description: 'Set package scripts according to each package role', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/packageScripts'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/packageScripts'), }, }); reg.addCommand({ path: ['migrate', 'package-exports'], description: 'Synchronize package subpath export definitions', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/packageExports'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/packageExports'), }, }); @@ -108,13 +93,8 @@ export default createCliPlugin({ path: ['migrate', 'package-lint-configs'], description: 'Migrates all packages to use @backstage/cli/config/eslint-factory', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/packageLintConfigs'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/packageLintConfigs'), }, }); @@ -122,13 +102,8 @@ export default createCliPlugin({ path: ['migrate', 'react-router-deps'], description: 'Migrates the react-router dependencies for all packages to be peer dependencies', - execute: async ({ args }) => { - const command = new Command(); - const defaultCommand = command.action( - lazy(() => import('./commands/reactRouterDeps'), 'command'), - ); - - await defaultCommand.parseAsync(args, { from: 'user' }); + execute: { + loader: () => import('./commands/reactRouterDeps'), }, }); }, diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index 0329b1241a..4ee5658489 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { dirname, resolve as resolvePath } from 'node:path'; @@ -28,16 +29,38 @@ import { } from '../lib/extractTranslations'; import { DEFAULT_LANGUAGE, + DEFAULT_MESSAGE_PATTERN, formatMessagePath, validatePattern, } from '../lib/messageFilePath'; +import type { CommandContext } from '../../../wiring/types'; -interface ExportOptions { - output: string; - pattern: string; -} +export default async ({ args, info }: CommandContext) => { + const { + flags: { output, pattern }, + } = cli( + { + help: info, + flags: { + output: { + type: String, + default: 'translations', + description: 'Output directory for exported messages and manifest', + }, + pattern: { + type: String, + default: DEFAULT_MESSAGE_PATTERN, + description: + 'File path pattern for message files, with {id} and {lang} placeholders', + }, + }, + }, + undefined, + args, + ); + + const options = { output, pattern }; -export default async (options: ExportOptions) => { validatePattern(options.pattern); const targetPackageJson = await readTargetPackage( diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts index c104275f46..67b600b495 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { targetPaths } from '@backstage/cli-common'; import fs from 'fs-extra'; import { @@ -27,11 +28,7 @@ import { createMessagePathParser, formatMessagePath, } from '../lib/messageFilePath'; - -interface ImportOptions { - input: string; - output: string; -} +import type { CommandContext } from '../../../wiring/types'; interface ManifestRefEntry { package: string; @@ -44,7 +41,31 @@ interface Manifest { refs: Record; } -export default async (options: ImportOptions) => { +export default async ({ args, info }: CommandContext) => { + const { + flags: { input, output }, + } = cli( + { + help: info, + flags: { + input: { + type: String, + default: 'translations', + description: + 'Input directory containing the manifest and translated message files', + }, + output: { + type: String, + default: 'src/translations/resources.ts', + description: 'Output path for the generated wiring module', + }, + }, + }, + undefined, + args, + ); + + const options = { input, output }; await readTargetPackage(targetPaths.dir, targetPaths.rootDir); const inputDir = resolvePath(targetPaths.dir, options.input); diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 1a700b3505..43263831d5 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -13,10 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import yargs from 'yargs'; import { createCliPlugin } from '../../wiring/factory'; -import { lazy } from '../../wiring/lazy'; -import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath'; export default createCliPlugin({ pluginId: 'translations', @@ -25,51 +22,14 @@ export default createCliPlugin({ path: ['translations', 'export'], description: 'Export translation messages from an app and all of its frontend plugins to JSON files', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - output: { - type: 'string', - default: 'translations', - description: - 'Output directory for exported messages and manifest', - }, - pattern: { - type: 'string', - default: DEFAULT_MESSAGE_PATTERN, - description: - 'File path pattern for message files, with {id} and {lang} placeholders', - }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/export'), 'default')(argv); - }, + execute: { loader: () => import('./commands/export') }, }); reg.addCommand({ path: ['translations', 'import'], description: 'Generate translation resource wiring from translated JSON files', - execute: async ({ args }) => { - const argv = await yargs() - .options({ - input: { - type: 'string', - default: 'translations', - description: - 'Input directory containing the manifest and translated message files', - }, - output: { - type: 'string', - default: 'src/translations/resources.ts', - description: 'Output path for the generated wiring module', - }, - }) - .help() - .parse(args); - await lazy(() => import('./commands/import'), 'default')(argv); - }, + execute: { loader: () => import('./commands/import') }, }); }, }); diff --git a/packages/cli/src/wiring/CliInitializer.test.ts b/packages/cli/src/wiring/CliInitializer.test.ts index 9ee252cfc5..e04a429ede 100644 --- a/packages/cli/src/wiring/CliInitializer.test.ts +++ b/packages/cli/src/wiring/CliInitializer.test.ts @@ -67,6 +67,31 @@ describe('CliInitializer', () => { expect(process.exit).toHaveBeenCalledWith(0); }); + it('should run commands using a loader', async () => { + expect.assertions(2); + process.argv = ['node', 'cli', 'test', '--verbose']; + const initializer = new CliInitializer(); + initializer.add( + createCliPlugin({ + pluginId: 'test', + init: async reg => + reg.addCommand({ + path: ['test'], + description: 'test', + execute: { + loader: async () => ({ + default: async ({ args }) => { + expect(args).toEqual(['--verbose']); + }, + }), + }, + }), + }), + ); + await initializer.run(); + expect(process.exit).toHaveBeenCalledWith(0); + }); + it('should pass positional args to the subcommand if nested', async () => { expect.assertions(2); process.argv = [ diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index a605199179..c07fbb26f3 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -118,13 +118,25 @@ export class CliInitializer { } positionalArgs.push(nonProcessArgs[argIndex]); } - await node.command.execute({ + const context = { args: [...positionalArgs, ...args.unknown], info: { usage: [programName, ...node.command.path].join(' '), description: node.command.description, }, - }); + }; + + if (typeof node.command.execute === 'function') { + await node.command.execute(context); + } else { + const mod = await node.command.execute.loader(); + // Handle CJS double-wrapping of default exports + const fn = + typeof mod.default === 'function' + ? mod.default + : (mod.default as any).default; + await fn(context); + } process.exit(0); } catch (error: unknown) { exitWithError(error); @@ -144,7 +156,7 @@ export class CliInitializer { exitWithError(new ForwardedError('Unhandled rejection', rejection)); }); - program.parse(process.argv); + await program.parseAsync(process.argv); } } diff --git a/packages/cli/src/wiring/types.ts b/packages/cli/src/wiring/types.ts index fc2c2b454d..e0f697990f 100644 --- a/packages/cli/src/wiring/types.ts +++ b/packages/cli/src/wiring/types.ts @@ -15,23 +15,31 @@ */ import { OpaqueType } from '@internal/opaque'; +export interface CommandContext { + args: string[]; + info: { + /** + * The usage string of the current command, for example: "backstage-cli repo test" + */ + usage: string; + /** + * The description provided for the command + */ + description: string; + }; +} + +export type CommandExecuteFn = (context: CommandContext) => Promise; + export interface BackstageCommand { path: string[]; description: string; deprecated?: boolean; - execute: (context: { - args: string[]; - info: { - /** - * The usage string of the current command, for example: "backstage-cli repo test" - */ - usage: string; - /** - * The description provided for the command - */ - description: string; - }; - }) => Promise; + execute: + | CommandExecuteFn + | { + loader: () => Promise<{ default: CommandExecuteFn }>; + }; } export type CliFeature = CliPlugin; diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 89e08206ce..2a85462335 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -5,6 +5,9 @@ "publishConfig": { "access": "public" }, + "backstage": { + "role": "cli" + }, "homepage": "https://backstage.io", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 9a951c6f98..e06cf2d7f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3352,6 +3352,7 @@ __metadata: buffer: "npm:^6.0.3" chalk: "npm:^4.0.0" chokidar: "npm:^3.3.1" + cleye: "npm:^2.2.1" commander: "npm:^14.0.3" cross-fetch: "npm:^4.0.0" cross-spawn: "npm:^7.0.3" @@ -27244,6 +27245,16 @@ __metadata: languageName: node linkType: hard +"cleye@npm:^2.2.1": + version: 2.2.1 + resolution: "cleye@npm:2.2.1" + dependencies: + terminal-columns: "npm:^2.0.0" + type-flag: "npm:^4.0.3" + checksum: 10/4d24a7e4863d38b9a40c01ca332baa7526b320c14fc488e7fb2d3f54b0ad5baa8c5defa6fc6adc079f50f629409056d9da74be6611f6394b709c9062d39b2216 + languageName: node + linkType: hard + "cli-boxes@npm:^2.2.0, cli-boxes@npm:^2.2.1": version: 2.2.1 resolution: "cli-boxes@npm:2.2.1" @@ -48462,6 +48473,13 @@ __metadata: languageName: node linkType: hard +"terminal-columns@npm:^2.0.0": + version: 2.0.0 + resolution: "terminal-columns@npm:2.0.0" + checksum: 10/f958993886e09d7c0780f47833316532a0c7dd7db2fb43da9d34a88c821633408a6e7084af59f99c20b045776814748f14a8e33573886186be7a30174092964f + languageName: node + linkType: hard + "terser-webpack-plugin@npm:*, terser-webpack-plugin@npm:^5.1.3, terser-webpack-plugin@npm:^5.3.16": version: 5.3.16 resolution: "terser-webpack-plugin@npm:5.3.16" @@ -49321,6 +49339,13 @@ __metadata: languageName: node linkType: hard +"type-flag@npm:^4.0.3": + version: 4.0.3 + resolution: "type-flag@npm:4.0.3" + checksum: 10/ffa9e56e50ed73ed802aa45b8d47eeba7677d2d66785e9153bfbc758c2613b6ba78b621d7766b713a287dbbe6758a61000f57d2ce8afc94e1876a37de914c73c + languageName: node + linkType: hard + "type-is@npm:^1.6.18, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18"