From 0d2d0f2e07ac4096e37793a95018a26976145128 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 19:27:34 +0100 Subject: [PATCH 01/15] Add lazy loader pattern for CLI command execution Extend `BackstageCommand.execute` to accept either a direct function or a `{ loader }` object for lazy loading command implementations. Convert several build and migrate commands to use the new pattern. Switch from `program.parse` to `program.parseAsync` to properly await async actions. Signed-off-by: Patrik Oldsberg --- .changeset/cli-execute-loader.md | 5 ++ packages/cli/package.json | 1 + .../src/modules/build/commands/repo/clean.ts | 2 +- packages/cli/src/modules/build/index.ts | 38 +++----- .../cli/src/modules/config/commands/docs.ts | 24 ++++- .../cli/src/modules/config/commands/print.ts | 55 +++++++++--- .../cli/src/modules/config/commands/schema.ts | 29 ++++-- .../src/modules/config/commands/validate.ts | 47 ++++++++-- packages/cli/src/modules/config/index.ts | 90 ++----------------- .../cli/src/modules/info/commands/info.ts | 33 +++++-- packages/cli/src/modules/info/index.ts | 24 +---- .../migrate/commands/packageExports.ts | 2 +- .../migrate/commands/packageLintConfigs.ts | 2 +- .../migrate/commands/packageScripts.ts | 2 +- .../migrate/commands/reactRouterDeps.ts | 2 +- packages/cli/src/modules/migrate/index.ts | 45 +++------- .../modules/translations/commands/export.ts | 33 +++++-- .../modules/translations/commands/import.ts | 33 +++++-- .../cli/src/modules/translations/index.ts | 44 +-------- .../cli/src/wiring/CliInitializer.test.ts | 25 ++++++ packages/cli/src/wiring/CliInitializer.ts | 18 +++- packages/cli/src/wiring/types.ts | 34 ++++--- packages/eslint-plugin/package.json | 3 + yarn.lock | 25 ++++++ 24 files changed, 338 insertions(+), 278 deletions(-) create mode 100644 .changeset/cli-execute-loader.md 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" From d5779e525c2f01faf4cb42e15a4b23599750c917 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 20:31:55 +0100 Subject: [PATCH 02/15] Fix CLI report generation and --help handling for loader-based commands Add cleye-based --help handling to all commands using the loader pattern. Update the CLI report parser to support cleye's USAGE: and FLAGS: sections. Revert accidental backstage.role addition to eslint-plugin. Signed-off-by: Patrik Oldsberg --- .changeset/cli-report-parser-cleye.md | 5 + packages/cli/cli-report.md | 93 +++++++++---------- .../modules/build/commands/package/clean.ts | 7 +- .../src/modules/build/commands/repo/clean.ts | 8 +- packages/cli/src/modules/build/index.ts | 7 +- .../migrate/commands/packageExports.ts | 8 +- .../migrate/commands/packageLintConfigs.ts | 7 +- .../modules/migrate/commands/packageRole.ts | 5 +- .../migrate/commands/packageScripts.ts | 7 +- .../migrate/commands/reactRouterDeps.ts | 7 +- packages/eslint-plugin/package.json | 3 - .../cli-reports/runCliExtraction.ts | 25 +++-- 12 files changed, 106 insertions(+), 76 deletions(-) create mode 100644 .changeset/cli-report-parser-cleye.md diff --git a/.changeset/cli-report-parser-cleye.md b/.changeset/cli-report-parser-cleye.md new file mode 100644 index 0000000000..f805aeb5d4 --- /dev/null +++ b/.changeset/cli-report-parser-cleye.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Updated CLI report parser to support cleye-style help output sections (`USAGE:` and `FLAGS:`). diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index ee4b03cbca..591b32ead7 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -57,81 +57,75 @@ Commands: ### `backstage-cli config docs` ``` -Usage: backstage-cli config docs [options] +Usage: backstage-cli config docs Options: - --package + --package -h, --help ``` ### `backstage-cli config schema` ``` -Usage: +Usage: backstage-cli config schema Options: - --format - --help + --format --merge - --no-merge - --package - --version + --package + -h, --help ``` ### `backstage-cli config:check` ``` -Usage: +Usage: backstage-cli config:check Options: - --config + --config --deprecated --frontend - --help --lax - --package + --package --strict - --version + -h, --help ``` ### `backstage-cli config:docs` ``` -Usage: program [options] +Usage: backstage-cli config:docs Options: - --package + --package -h, --help ``` ### `backstage-cli config:print` ``` -Usage: +Usage: backstage-cli config:print Options: - --config - --format + --config + --format --frontend - --help --lax - --package - --version + --package --with-secrets + -h, --help ``` ### `backstage-cli config:schema` ``` -Usage: +Usage: backstage-cli config:schema Options: - --format - --help + --format --merge - --no-merge - --package - --version + --package + -h, --help ``` ### `backstage-cli create-github-app` @@ -146,13 +140,12 @@ Options: ### `backstage-cli info` ``` -Usage: +Usage: backstage-cli info Options: - --format - --help - --include - --version + --format + --include + -h, --help ``` ### `backstage-cli migrate` @@ -175,7 +168,7 @@ Commands: ### `backstage-cli migrate package-exports` ``` -Usage: program [options] +Usage: backstage-cli migrate package-exports Options: -h, --help @@ -184,7 +177,7 @@ Options: ### `backstage-cli migrate package-lint-configs` ``` -Usage: program [options] +Usage: backstage-cli migrate package-lint-configs Options: -h, --help @@ -193,7 +186,7 @@ Options: ### `backstage-cli migrate package-roles` ``` -Usage: program [options] +Usage: backstage-cli migrate package-roles Options: -h, --help @@ -202,7 +195,7 @@ Options: ### `backstage-cli migrate package-scripts` ``` -Usage: program [options] +Usage: backstage-cli migrate package-scripts Options: -h, --help @@ -211,7 +204,7 @@ Options: ### `backstage-cli migrate react-router-deps` ``` -Usage: program [options] +Usage: backstage-cli migrate react-router-deps Options: -h, --help @@ -271,7 +264,7 @@ Options: ### `backstage-cli package clean` ``` -Usage: program [options] +Usage: backstage-cli package clean Options: -h, --help @@ -293,7 +286,7 @@ Options: ### `backstage-cli package postpack` ``` -Usage: program [options] +Usage: backstage-cli package postpack Options: -h, --help @@ -302,7 +295,7 @@ Options: ### `backstage-cli package prepack` ``` -Usage: program [options] +Usage: backstage-cli package prepack Options: -h, --help @@ -472,7 +465,7 @@ Options: ### `backstage-cli repo clean` ``` -Usage: program [options] +Usage: backstage-cli repo clean Options: -h, --help @@ -560,25 +553,23 @@ Commands: ### `backstage-cli translations export` ``` -Usage: +Usage: backstage-cli translations export Options: - --help - --output - --pattern - --version + --output + --pattern + -h, --help ``` ### `backstage-cli translations import` ``` -Usage: +Usage: backstage-cli translations import Options: - --help - --input - --output - --version + --input + --output + -h, --help ``` ### `backstage-cli versions:bump` diff --git a/packages/cli/src/modules/build/commands/package/clean.ts b/packages/cli/src/modules/build/commands/package/clean.ts index 70d7ea3771..f222c23666 100644 --- a/packages/cli/src/modules/build/commands/package/clean.ts +++ b/packages/cli/src/modules/build/commands/package/clean.ts @@ -14,11 +14,14 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { targetPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; -export default async function clean() { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); await fs.remove(targetPaths.resolve('dist')); await fs.remove(targetPaths.resolve('dist-types')); await fs.remove(targetPaths.resolve('coverage')); -} +}; diff --git a/packages/cli/src/modules/build/commands/repo/clean.ts b/packages/cli/src/modules/build/commands/repo/clean.ts index a3c6e9cbbd..efafdae510 100644 --- a/packages/cli/src/modules/build/commands/repo/clean.ts +++ b/packages/cli/src/modules/build/commands/repo/clean.ts @@ -14,13 +14,15 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; - import { run, targetPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../../wiring/types'; -export default async function command(): Promise { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); const packages = await PackageGraph.listTargetPackages(); await fs.remove(targetPaths.resolveRoot('dist')); @@ -48,4 +50,4 @@ export default async function command(): Promise { } }), ); -} +}; diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 7a116f5d8a..fd94cb5db6 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { cli } from 'cleye'; import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; import { lazy } from '../../wiring/lazy'; @@ -214,7 +215,8 @@ export const buildPlugin = createCliPlugin({ reg.addCommand({ path: ['package', 'prepack'], description: 'Prepares a package for packaging before publishing', - execute: async () => { + execute: async ({ args, info }) => { + cli({ help: info }, undefined, args); const { pre } = await import('./commands/package/pack'); await pre(); }, @@ -223,7 +225,8 @@ export const buildPlugin = createCliPlugin({ reg.addCommand({ path: ['package', 'postpack'], description: 'Restores the changes made by the prepack command', - execute: async () => { + execute: async ({ args, info }) => { + cli({ help: info }, undefined, args); const { post } = await import('./commands/package/pack'); await post(); }, diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli/src/modules/migrate/commands/packageExports.ts index 581c334c6f..3307e55721 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli/src/modules/migrate/commands/packageExports.ts @@ -14,8 +14,12 @@ * limitations under the License. */ -export default async function command() { +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); 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 4df76a51ac..c6839fe28b 100644 --- a/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts +++ b/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts @@ -14,14 +14,17 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; import { runOutput } from '@backstage/cli-common'; +import type { CommandContext } from '../../../wiring/types'; const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`; -export default async function command() { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); const packages = await PackageGraph.listTargetPackages(); const oldConfigs = [ @@ -86,4 +89,4 @@ export default async function command() { if (hasPrettier) { await runOutput(['prettier', '--write', ...configPaths]); } -} +}; diff --git a/packages/cli/src/modules/migrate/commands/packageRole.ts b/packages/cli/src/modules/migrate/commands/packageRole.ts index 6b422c375e..8d8c04b735 100644 --- a/packages/cli/src/modules/migrate/commands/packageRole.ts +++ b/packages/cli/src/modules/migrate/commands/packageRole.ts @@ -14,13 +14,16 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { getPackages } from '@manypkg/get-packages'; import { PackageRoles } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; +import type { CommandContext } from '../../../wiring/types'; -export default async () => { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); const { packages } = await getPackages(targetPaths.dir); await Promise.all( diff --git a/packages/cli/src/modules/migrate/commands/packageScripts.ts b/packages/cli/src/modules/migrate/commands/packageScripts.ts index f875bf14fc..e76c684a6f 100644 --- a/packages/cli/src/modules/migrate/commands/packageScripts.ts +++ b/packages/cli/src/modules/migrate/commands/packageScripts.ts @@ -14,15 +14,18 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph, PackageRoles, PackageRole } from '@backstage/cli-node'; +import type { CommandContext } from '../../../wiring/types'; const configArgPattern = /--config[=\s][^\s$]+/; const noStartRoles: PackageRole[] = ['cli', 'common-library']; -export default async function command() { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); const packages = await PackageGraph.listTargetPackages(); await Promise.all( @@ -104,4 +107,4 @@ export default async function command() { } }), ); -} +}; diff --git a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts b/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts index 3c2cf46560..702593c366 100644 --- a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts +++ b/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts @@ -14,14 +14,17 @@ * limitations under the License. */ +import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph, PackageRoles } from '@backstage/cli-node'; +import type { CommandContext } from '../../../wiring/types'; const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom']; const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0'; -export default async function command() { +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); const packages = await PackageGraph.listTargetPackages(); await Promise.all( @@ -56,4 +59,4 @@ export default async function command() { } }), ); -} +}; diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 2a85462335..89e08206ce 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -5,9 +5,6 @@ "publishConfig": { "access": "public" }, - "backstage": { - "role": "cli" - }, "homepage": "https://backstage.io", "repository": { "type": "git", diff --git a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts index 146c83bcfb..ae49c95f39 100644 --- a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts +++ b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts @@ -27,7 +27,14 @@ import { generateCliReport } from './generateCliReport'; import { logApiReportInstructions } from '../common'; function parseHelpPage(helpPageContent: string) { - const [, usage] = helpPageContent.match(/^\s*Usage: (.*)$/im) ?? []; + let usage: string | undefined; + + // Commander format: "Usage: backstage-cli ..." + const commanderUsage = helpPageContent.match(/^\s*Usage: (.*)$/im); + if (commanderUsage) { + usage = commanderUsage[1]; + } + const lines = helpPageContent.split(/\r?\n/); let options = new Array(); @@ -39,8 +46,8 @@ function parseHelpPage(helpPageContent: string) { lines.shift(); } if (lines.length > 0) { - // Start of a new section, e.g. "Options:" - const sectionName = lines.shift(); + // Start of a new section, e.g. "Options:" or "FLAGS:" + const sectionName = lines.shift()?.toLocaleLowerCase('en-US'); // Take lines until we hit the next section or the end const sectionEndIndex = lines.findIndex( line => line && !line.match(/^\s/), @@ -53,12 +60,18 @@ function parseHelpPage(helpPageContent: string) { .map(line => line.match(/^\s{1,8}(.*?)\s\s+/)?.[1]) .filter(Boolean) as string[]; - if (sectionName?.toLocaleLowerCase('en-US') === 'options:') { + if (sectionName === 'options:' || sectionName === 'flags:') { options = sectionItems; - } else if (sectionName?.toLocaleLowerCase('en-US') === 'commands:') { + } else if (sectionName === 'commands:') { commands = sectionItems; - } else if (sectionName?.toLocaleLowerCase('en-US') === 'arguments:') { + } else if (sectionName === 'arguments:') { commandArguments = sectionItems; + } else if (sectionName === 'usage:') { + // cleye format: usage line is inside the USAGE: section + const usageLine = sectionLines.find(l => l.trim().length > 0)?.trim(); + if (usageLine) { + usage = usageLine; + } } else { throw new Error(`Unknown CLI section: ${sectionName}`); } From fab449cf9e6979b83f7b95ec8087471d4688c570 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 23:43:46 +0100 Subject: [PATCH 03/15] cli: lint fix Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../cli/src/modules/migrate/commands/packageScripts.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/modules/migrate/commands/packageScripts.ts b/packages/cli/src/modules/migrate/commands/packageScripts.ts index e76c684a6f..0b7d9fc2ce 100644 --- a/packages/cli/src/modules/migrate/commands/packageScripts.ts +++ b/packages/cli/src/modules/migrate/commands/packageScripts.ts @@ -59,14 +59,14 @@ export default async ({ args, info }: CommandContext) => { // For test scripts we keep all existing flags except for --passWithNoTests, since that's now default const testCmd = ['test']; if (scripts.test?.startsWith('backstage-cli test')) { - const args = scripts.test + const testArgs = scripts.test .slice('backstage-cli test'.length) .split(' ') .filter(Boolean); - if (args.includes('--passWithNoTests')) { - args.splice(args.indexOf('--passWithNoTests'), 1); + if (testArgs.includes('--passWithNoTests')) { + testArgs.splice(testArgs.indexOf('--passWithNoTests'), 1); } - testCmd.push(...args); + testCmd.push(...testArgs); } const expectedScripts = { From b4fea2bc29f59a5a2580f67ce5d6cbd9abdc7f5e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 23:54:48 +0100 Subject: [PATCH 04/15] repo-tools: Use spawnSync for CLI report extraction Use spawnSync instead of spawn for child processes in createBinRunner. This avoids stdout data loss when child processes call process.exit() before Node.js flushes its internal stream buffer to the pipe. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 72 ++++++++---------------- 1 file changed, 24 insertions(+), 48 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index 3ca8fa2f17..c1bf6e2a20 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -14,58 +14,34 @@ * limitations under the License. */ -import { spawn } from 'node:child_process'; -import os from 'node:os'; -import pLimit from 'p-limit'; - -// Some commands launch full node processes doing heavy work, which at high -// concurrency levels risk exhausting system resources. Placing the limiter here -// at the root level ensures that the concurrency boundary applies globally, not -// just per-runner. -const limiter = pLimit(os.cpus().length); +import { spawnSync } from 'node:child_process'; export function createBinRunner(cwd: string, path: string) { - return async (...command: string[]) => - limiter( - () => - new Promise((resolve, reject) => { - // Handle the case where path is empty and the script path is the first command argument - const args = path ? [path, ...command] : command; - const child = spawn('node', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - }); + return async (...command: string[]) => { + const args = path ? [path, ...command] : command; + const result = spawnSync('node', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }); - let stdout = ''; - let stderr = ''; + if (result.error) { + throw new Error(`Process error: ${result.error.message}`); + } - child.stdout?.on('data', data => { - stdout += data.toString(); - }); + const stderr = result.stderr?.toString() ?? ''; + const stdout = result.stdout?.toString() ?? ''; - child.stderr?.on('data', data => { - stderr += data.toString(); - }); + if (result.signal) { + throw new Error( + `Process was killed with signal ${result.signal}\n${stderr}`, + ); + } else if (result.status !== 0) { + throw new Error(`Process exited with code ${result.status}\n${stderr}`); + } else if (stderr.trim()) { + throw new Error(`Command printed error output: ${stderr}`); + } - child.on('error', err => { - reject(new Error(`Process error: ${err.message}`)); - }); - - child.on('close', (code, signal) => { - if (signal) { - reject( - new Error( - `Process was killed with signal ${signal}\n${stderr}`, - ), - ); - } else if (code !== 0) { - reject(new Error(`Process exited with code ${code}\n${stderr}`)); - } else if (stderr.trim()) { - reject(new Error(`Command printed error output: ${stderr}`)); - } else { - resolve(stdout); - } - }); - }), - ); + return stdout; + }; } From a2653cd11270785c728bceaa6481bb478510f5b8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 23:56:23 +0100 Subject: [PATCH 05/15] Update repo-tools changeset to cover spawnSync change Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/cli-report-parser-cleye.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-report-parser-cleye.md b/.changeset/cli-report-parser-cleye.md index f805aeb5d4..f374abb097 100644 --- a/.changeset/cli-report-parser-cleye.md +++ b/.changeset/cli-report-parser-cleye.md @@ -2,4 +2,4 @@ '@backstage/repo-tools': patch --- -Updated CLI report parser to support cleye-style help output sections (`USAGE:` and `FLAGS:`). +Updated CLI report parser to support cleye-style help output sections (`USAGE:` and `FLAGS:`). Switched to synchronous child process execution for CLI report extraction to avoid stdout data loss. From d9dde597c9b2cf5c20a0ca4d9cf10f598360c85e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 01:21:53 +0100 Subject: [PATCH 06/15] repo-tools: Redirect child stdout to file for CLI reports Use a temp file instead of a pipe for child process stdout in createBinRunner. Node.js uses a SyncWriteStream for regular files, so all writes complete synchronously before process.exit() can discard them. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 69 ++++++++++++++++-------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index c1bf6e2a20..bce2359589 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,33 +15,58 @@ */ import { spawnSync } from 'node:child_process'; +import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +/** + * Redirect stdout to a temp file so that Node.js creates a SyncWriteStream + * (synchronous writes) in the child instead of an async pipe stream. This + * prevents data loss when child processes call process.exit() before the + * async stream buffer has been flushed. + */ export function createBinRunner(cwd: string, path: string) { return async (...command: string[]) => { const args = path ? [path, ...command] : command; - const result = spawnSync('node', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }); + const outPath = join( + tmpdir(), + `backstage-cli-out-${process.pid}-${Date.now()}.txt`, + ); + const outFd = openSync(outPath, 'w'); - if (result.error) { - throw new Error(`Process error: ${result.error.message}`); + try { + const result = spawnSync('node', args, { + cwd, + stdio: ['ignore', outFd, 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }); + + closeSync(outFd); + const stdout = readFileSync(outPath, 'utf8'); + + if (result.error) { + throw new Error(`Process error: ${result.error.message}`); + } + + const stderr = result.stderr?.toString() ?? ''; + + if (result.signal) { + throw new Error( + `Process was killed with signal ${result.signal}\n${stderr}`, + ); + } else if (result.status !== 0) { + throw new Error(`Process exited with code ${result.status}\n${stderr}`); + } else if (stderr.trim()) { + throw new Error(`Command printed error output: ${stderr}`); + } + + return stdout; + } finally { + try { + unlinkSync(outPath); + } catch { + /* ignore cleanup errors */ + } } - - const stderr = result.stderr?.toString() ?? ''; - const stdout = result.stdout?.toString() ?? ''; - - if (result.signal) { - throw new Error( - `Process was killed with signal ${result.signal}\n${stderr}`, - ); - } else if (result.status !== 0) { - throw new Error(`Process exited with code ${result.status}\n${stderr}`); - } else if (stderr.trim()) { - throw new Error(`Command printed error output: ${stderr}`); - } - - return stdout; }; } From 5e01ab714a1d68091383a1a4135d7fd4543b66f8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 08:46:44 +0100 Subject: [PATCH 07/15] repo-tools: Add _handle stub preload for file-backed stdout Add a --require preload that stubs process.stdout._handle when stdout is a SyncWriteStream (file-backed). Some CLI code accesses _handle.setBlocking directly and would crash without the stub. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 48 +++++++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index bce2359589..c56a98fc04 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,10 +15,42 @@ */ import { spawnSync } from 'node:child_process'; -import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; +import { + openSync, + closeSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +// Preload script that stubs process.stdout._handle when stdout is backed by a +// file (SyncWriteStream). Some CLI code accesses _handle.setBlocking directly +// and would crash without this. +const preloadContent = ` +if (process.stdout && !process.stdout._handle) { + process.stdout._handle = { setBlocking() {} }; +} +`; + +let preloadPath: string | undefined; + +function getPreloadPath() { + if (!preloadPath) { + preloadPath = join(tmpdir(), `backstage-stdout-preload-${process.pid}.cjs`); + writeFileSync(preloadPath, preloadContent); + process.on('exit', () => { + try { + unlinkSync(preloadPath!); + } catch { + /* ignore */ + } + }); + } + return preloadPath; +} + /** * Redirect stdout to a temp file so that Node.js creates a SyncWriteStream * (synchronous writes) in the child instead of an async pipe stream. This @@ -35,11 +67,15 @@ export function createBinRunner(cwd: string, path: string) { const outFd = openSync(outPath, 'w'); try { - const result = spawnSync('node', args, { - cwd, - stdio: ['ignore', outFd, 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }); + const result = spawnSync( + 'node', + ['--require', getPreloadPath(), ...args], + { + cwd, + stdio: ['ignore', outFd, 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }, + ); closeSync(outFd); const stdout = readFileSync(outPath, 'utf8'); From 2481126013fad85be8e7f213f5113ae061648bc4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 08:48:19 +0100 Subject: [PATCH 08/15] Remove setBlocking calls and simplify file-redirect approach Remove fragile process.stdout._handle.setBlocking calls from CLI test commands. Revert the preload workaround in repo-tools since the root cause is removed. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/modules/test/commands/package/test.ts | 5 -- .../src/modules/test/commands/repo/test.ts | 2 - packages/repo-tools/src/commands/util.ts | 48 +++---------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/modules/test/commands/package/test.ts b/packages/cli/src/modules/test/commands/package/test.ts index c4acfb287b..9c13019bb5 100644 --- a/packages/cli/src/modules/test/commands/package/test.ts +++ b/packages/cli/src/modules/test/commands/package/test.ts @@ -87,11 +87,6 @@ export default async (_opts: OptionValues, cmd: Command) => { }--no-node-snapshot`; } - // This ensures that the process doesn't exit too early before stdout is flushed - if (args.includes('--help')) { - (process.stdout as any)._handle.setBlocking(true); - } - // Because of the ongoing migration to v30 of jest, jest is no longer hard-depended to allow // opt-in migration. Users instead need to add jest as a devDependency themselves and specify // the version they want. This prints a helpful error message if jest is not found, i.e. they diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli/src/modules/test/commands/repo/test.ts index 53f86fd635..bb614798b4 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli/src/modules/test/commands/repo/test.ts @@ -304,11 +304,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise { }--no-node-snapshot`; } - // This ensures that the process doesn't exit too early before stdout is flushed if (args.includes('--jest-help')) { removeOptionArg(args, '--jest-help'); args.push('--help'); - (process.stdout as any)._handle.setBlocking(true); } // This code path is enabled by the --successCache flag, which is specific to diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index c56a98fc04..bce2359589 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,42 +15,10 @@ */ import { spawnSync } from 'node:child_process'; -import { - openSync, - closeSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; +import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -// Preload script that stubs process.stdout._handle when stdout is backed by a -// file (SyncWriteStream). Some CLI code accesses _handle.setBlocking directly -// and would crash without this. -const preloadContent = ` -if (process.stdout && !process.stdout._handle) { - process.stdout._handle = { setBlocking() {} }; -} -`; - -let preloadPath: string | undefined; - -function getPreloadPath() { - if (!preloadPath) { - preloadPath = join(tmpdir(), `backstage-stdout-preload-${process.pid}.cjs`); - writeFileSync(preloadPath, preloadContent); - process.on('exit', () => { - try { - unlinkSync(preloadPath!); - } catch { - /* ignore */ - } - }); - } - return preloadPath; -} - /** * Redirect stdout to a temp file so that Node.js creates a SyncWriteStream * (synchronous writes) in the child instead of an async pipe stream. This @@ -67,15 +35,11 @@ export function createBinRunner(cwd: string, path: string) { const outFd = openSync(outPath, 'w'); try { - const result = spawnSync( - 'node', - ['--require', getPreloadPath(), ...args], - { - cwd, - stdio: ['ignore', outFd, 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }, - ); + const result = spawnSync('node', args, { + cwd, + stdio: ['ignore', outFd, 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }); closeSync(outFd); const stdout = readFileSync(outPath, 'utf8'); From f586daee1f1a1d467dc3e6cca7c7820b411fb971 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 09:59:15 +0100 Subject: [PATCH 09/15] repo-tools: Add debug logging for empty stdout in createBinRunner Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index bce2359589..a67b3335bf 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,7 +15,13 @@ */ import { spawnSync } from 'node:child_process'; -import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; +import { + openSync, + closeSync, + readFileSync, + unlinkSync, + statSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -42,8 +48,19 @@ export function createBinRunner(cwd: string, path: string) { }); closeSync(outFd); + const fileSize = statSync(outPath).size; const stdout = readFileSync(outPath, 'utf8'); + // Temporary debug: log when file redirect produces no output + if (stdout.length === 0) { + console.error( + `[createBinRunner debug] empty stdout for: node ${args.join(' ')} ` + + `(exit=${result.status}, fileSize=${fileSize}, stderr=${( + result.stderr?.toString() ?? '' + ).slice(0, 200)})`, + ); + } + if (result.error) { throw new Error(`Process error: ${result.error.message}`); } From b7c9a8df12986d0145fc268d228f8b2694d89a76 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 10:11:23 +0100 Subject: [PATCH 10/15] repo-tools: Add module-level debug log to verify util.ts is loaded Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index a67b3335bf..a3db36d2e8 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -25,6 +25,8 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; +console.error('[util.ts] createBinRunner module loaded'); + /** * Redirect stdout to a temp file so that Node.js creates a SyncWriteStream * (synchronous writes) in the child instead of an async pipe stream. This From 3a1054f51d0dbe8a002d75fed424cd486f800d16 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 10:21:09 +0100 Subject: [PATCH 11/15] repo-tools: Log stdout content when help markers missing Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index a3db36d2e8..c64368f169 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -53,13 +53,15 @@ export function createBinRunner(cwd: string, path: string) { const fileSize = statSync(outPath).size; const stdout = readFileSync(outPath, 'utf8'); - // Temporary debug: log when file redirect produces no output - if (stdout.length === 0) { + // Temporary debug: log stdout when no help markers found + if (!stdout.includes('Usage:') && !stdout.includes('USAGE:')) { console.error( - `[createBinRunner debug] empty stdout for: node ${args.join(' ')} ` + - `(exit=${result.status}, fileSize=${fileSize}, stderr=${( - result.stderr?.toString() ?? '' - ).slice(0, 200)})`, + `[createBinRunner debug] no help markers for: node ${args.join( + ' ', + )} ` + + `(exit=${result.status}, size=${fileSize}, content=${JSON.stringify( + stdout.slice(0, 300), + )})`, ); } From f2a8299d7a51c98624f3c5d7053257210d308d3d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 10:33:15 +0100 Subject: [PATCH 12/15] repo-tools: Log all command outputs for debugging Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index c64368f169..11f44a15d6 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -53,17 +53,14 @@ export function createBinRunner(cwd: string, path: string) { const fileSize = statSync(outPath).size; const stdout = readFileSync(outPath, 'utf8'); - // Temporary debug: log stdout when no help markers found - if (!stdout.includes('Usage:') && !stdout.includes('USAGE:')) { - console.error( - `[createBinRunner debug] no help markers for: node ${args.join( - ' ', - )} ` + - `(exit=${result.status}, size=${fileSize}, content=${JSON.stringify( - stdout.slice(0, 300), - )})`, - ); - } + // Temporary debug: log first 200 chars of every command + console.error( + `[debug] ${args + .slice(-2) + .join(' ')} size=${fileSize} out=${JSON.stringify( + stdout.slice(0, 200), + )}`, + ); if (result.error) { throw new Error(`Process error: ${result.error.message}`); From aac8cd4cc27ed3ec4677ad90fcce155bfd9441fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 10:45:56 +0100 Subject: [PATCH 13/15] repo-tools: Strip ANSI codes and set NO_COLOR for CLI report extraction Cleye outputs ANSI bold escape sequences around section headers (e.g. Usage:, Flags:) unconditionally, which prevented the help page parser from recognizing them. Strip ANSI SGR sequences from captured output and set NO_COLOR=1 in the child environment. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 25 +++++------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index 11f44a15d6..2d7425336b 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,17 +15,12 @@ */ import { spawnSync } from 'node:child_process'; -import { - openSync, - closeSync, - readFileSync, - unlinkSync, - statSync, -} from 'node:fs'; +import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -console.error('[util.ts] createBinRunner module loaded'); +// Matches ANSI SGR escape sequences (e.g. bold, color, reset) +const ansiPattern = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); /** * Redirect stdout to a temp file so that Node.js creates a SyncWriteStream @@ -45,22 +40,12 @@ export function createBinRunner(cwd: string, path: string) { try { const result = spawnSync('node', args, { cwd, + env: { ...process.env, NO_COLOR: '1' }, stdio: ['ignore', outFd, 'pipe'], - maxBuffer: 10 * 1024 * 1024, }); closeSync(outFd); - const fileSize = statSync(outPath).size; - const stdout = readFileSync(outPath, 'utf8'); - - // Temporary debug: log first 200 chars of every command - console.error( - `[debug] ${args - .slice(-2) - .join(' ')} size=${fileSize} out=${JSON.stringify( - stdout.slice(0, 200), - )}`, - ); + const stdout = readFileSync(outPath, 'utf8').replace(ansiPattern, ''); if (result.error) { throw new Error(`Process error: ${result.error.message}`); From 98103107817e4e6869b9c26da0dad570ce3013ff Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 12:56:54 +0100 Subject: [PATCH 14/15] Update changeset messages per review feedback Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/cli-execute-loader.md | 2 +- .changeset/cli-report-parser-cleye.md | 2 +- .github/vale/config/vocabularies/Backstage/accept.txt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/cli-execute-loader.md b/.changeset/cli-execute-loader.md index 4636468864..ce9665e01d 100644 --- a/.changeset/cli-execute-loader.md +++ b/.changeset/cli-execute-loader.md @@ -2,4 +2,4 @@ '@backstage/cli': patch --- -Added support for lazy loading of CLI command implementations through a new loader pattern for `BackstageCommand.execute`. +Internal refactor of CLI modularization, moving individual commands to be implemented with cleye. diff --git a/.changeset/cli-report-parser-cleye.md b/.changeset/cli-report-parser-cleye.md index f374abb097..f8d9141c66 100644 --- a/.changeset/cli-report-parser-cleye.md +++ b/.changeset/cli-report-parser-cleye.md @@ -2,4 +2,4 @@ '@backstage/repo-tools': patch --- -Updated CLI report parser to support cleye-style help output sections (`USAGE:` and `FLAGS:`). Switched to synchronous child process execution for CLI report extraction to avoid stdout data loss. +Updated the CLI report parser to support cleye-style help output, and strip ANSI escape codes from captured output. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index f6f37b17cf..cbe8ee2adf 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -576,3 +576,4 @@ zsh resizable enums LLMs +cleye From 992e37b93f48062cd7732dbe98b6db9c221c0e60 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Feb 2026 14:56:22 +0100 Subject: [PATCH 15/15] repo-tools: Use randomUUID for temp files and document sequential execution Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/repo-tools/src/commands/util.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/repo-tools/src/commands/util.ts b/packages/repo-tools/src/commands/util.ts index 2d7425336b..bb29cdf441 100644 --- a/packages/repo-tools/src/commands/util.ts +++ b/packages/repo-tools/src/commands/util.ts @@ -15,6 +15,7 @@ */ import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,14 +28,14 @@ const ansiPattern = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); * (synchronous writes) in the child instead of an async pipe stream. This * prevents data loss when child processes call process.exit() before the * async stream buffer has been flushed. + * + * Uses spawnSync which blocks the event loop, so no concurrency limiter is + * needed — each call naturally runs sequentially. */ export function createBinRunner(cwd: string, path: string) { return async (...command: string[]) => { const args = path ? [path, ...command] : command; - const outPath = join( - tmpdir(), - `backstage-cli-out-${process.pid}-${Date.now()}.txt`, - ); + const outPath = join(tmpdir(), `backstage-cli-out-${randomUUID()}.txt`); const outFd = openSync(outPath, 'w'); try {