From c95a130f52115d0c3cac875d4a786879b704f36c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 13 Mar 2026 15:40:15 +0100 Subject: [PATCH] Add runCliModule helper for standalone module execution Add a public `runCliModule` function to `@backstage/cli-node` that allows CLI module packages to be executed directly as standalone programs, without needing to be wired into a larger CLI host. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli-node/package.json | 2 + packages/cli-node/report.api.md | 7 + packages/cli-node/src/cli-module/index.ts | 1 + .../cli-node/src/cli-module/runCliModule.ts | 232 ++++++++++++++++++ yarn.lock | 4 +- 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 packages/cli-node/src/cli-module/runCliModule.ts diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 4fa5af208c..95322ad2f0 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -37,6 +37,8 @@ "@manypkg/get-packages": "^1.1.3", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "^3.0.0", + "chalk": "^4.0.0", + "commander": "^12.0.0", "fs-extra": "^11.2.0", "semver": "^7.5.3", "yaml": "^2.0.0", diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index b979fff0b0..2fac82080b 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -253,6 +253,13 @@ export class PackageRoles { static getRoleInfo(role: string): PackageRoleInfo; } +// @public +export function runCliModule(options: { + module: CliModule; + name: string; + version?: string; +}): Promise; + // @public export function runConcurrentTasks( options: ConcurrentTasksOptions, diff --git a/packages/cli-node/src/cli-module/index.ts b/packages/cli-node/src/cli-module/index.ts index 646073a815..56324c3423 100644 --- a/packages/cli-node/src/cli-module/index.ts +++ b/packages/cli-node/src/cli-module/index.ts @@ -15,4 +15,5 @@ */ export { createCliModule } from './createCliModule'; +export { runCliModule } from './runCliModule'; export type { CliCommand, CliCommandContext, CliModule } from './types'; diff --git a/packages/cli-node/src/cli-module/runCliModule.ts b/packages/cli-node/src/cli-module/runCliModule.ts new file mode 100644 index 0000000000..84893f0cac --- /dev/null +++ b/packages/cli-node/src/cli-module/runCliModule.ts @@ -0,0 +1,232 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OpaqueCliModule, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, +} from '@internal/cli'; +import type { CommandNode } from '@internal/cli'; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { isError, stringifyError } from '@backstage/errors'; +import type { CliModule, CliCommand } from './types'; + +function isCommandHidden(node: CommandNode): boolean { + if (OpaqueCommandLeafNode.isType(node)) { + const { command } = OpaqueCommandLeafNode.toInternal(node); + return !!command.deprecated || !!command.experimental; + } + const { children } = OpaqueCommandTreeNode.toInternal(node); + return children.every(child => isCommandHidden(child)); +} + +function buildCommandGraph(commands: ReadonlyArray): CommandNode[] { + const graph: CommandNode[] = []; + + for (const command of commands) { + const { path } = command; + let current = graph; + + for (let i = 0; i < path.length - 1; i++) { + const name = path[i]; + let next = current.find( + n => + OpaqueCommandTreeNode.isType(n) && + OpaqueCommandTreeNode.toInternal(n).name === name, + ); + if (!next) { + next = OpaqueCommandTreeNode.createInstance('v1', { + name, + children: [], + }); + current.push(next); + } + current = OpaqueCommandTreeNode.toInternal(next).children; + } + + current.push( + OpaqueCommandLeafNode.createInstance('v1', { + name: path[path.length - 1], + command, + }), + ); + } + + return graph; +} + +function exitWithError(error: unknown): never { + if (!isError(error)) { + process.stderr.write(`\n${chalk.red(stringifyError(error))}\n\n`); + process.exit(1); + } + process.stderr.write(`\n${chalk.red(stringifyError(error))}\n\n`); + process.exit( + 'code' in error && typeof error.code === 'number' ? error.code : 1, + ); +} + +function registerCommands( + graph: CommandNode[], + program: Command, + programName: string, +): void { + const queue = graph.map(node => ({ node, argParser: program })); + + while (queue.length) { + const { node, argParser } = queue.shift()!; + + if (OpaqueCommandTreeNode.isType(node)) { + const internal = OpaqueCommandTreeNode.toInternal(node); + const treeParser = argParser + .command(`${internal.name} [command]`, { + hidden: isCommandHidden(node), + }) + .description(internal.name); + + queue.push( + ...internal.children.map(child => ({ + node: child, + argParser: treeParser, + })), + ); + } else { + const internal = OpaqueCommandLeafNode.toInternal(node); + argParser + .command(internal.name, { + hidden: + !!internal.command.deprecated || !!internal.command.experimental, + }) + .description(internal.command.description) + .helpOption(false) + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + const args = program.parseOptions(process.argv); + + const nonProcessArgs = args.operands.slice(2); + const positionalArgs = []; + let index = 0; + for ( + let argIndex = 0; + argIndex < nonProcessArgs.length; + argIndex++ + ) { + if ( + argIndex === index && + internal.command.path[argIndex] === nonProcessArgs[argIndex] + ) { + index += 1; + continue; + } + positionalArgs.push(nonProcessArgs[argIndex]); + } + const context = { + args: [...positionalArgs, ...args.unknown], + info: { + usage: [programName, ...internal.command.path].join(' '), + name: internal.command.path.join(' '), + }, + }; + + if (typeof internal.command.execute === 'function') { + await internal.command.execute(context); + } else { + const mod = await internal.command.execute.loader(); + const fn = + typeof mod.default === 'function' + ? mod.default + : (mod.default as any).default; + await fn(context); + } + process.exit(0); + } catch (error: unknown) { + exitWithError(error); + } + }); + } + } +} + +/** + * Runs a CLI module as a standalone program. + * + * This helper extracts the commands from a {@link CliModule} and exposes + * them as a fully functional CLI with help output and argument parsing. + * It is intended to be called from a module package's `bin` entry point + * so that the module can be executed directly without being wired into + * a larger CLI host. + * + * @example + * ```ts + * #!/usr/bin/env node + * import { runCliModule } from '@backstage/cli-node'; + * import cliModule from './index'; + * + * runCliModule({ + * module: cliModule, + * name: 'backstage-auth', + * version: require('../package.json').version, + * }); + * ``` + * + * @public + */ +export async function runCliModule(options: { + /** The CLI module to run. */ + module: CliModule; + /** The program name shown in help output and usage strings. */ + name: string; + /** The version string shown when `--version` is passed. */ + version?: string; +}): Promise { + const { module: cliModule, name, version } = options; + + if (!OpaqueCliModule.isType(cliModule)) { + throw new Error( + `Invalid CLI module: expected a module created with createCliModule`, + ); + } + + const internal = OpaqueCliModule.toInternal(cliModule); + const commands = await internal.commands; + const graph = buildCommandGraph(commands); + + const program = new Command(); + program.name(name).allowUnknownOption(true).allowExcessArguments(true); + + if (version) { + program.version(version); + } + + registerCommands(graph, program, name); + + program.on('command:*', () => { + console.log(); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); + console.log(); + program.outputHelp(); + process.exit(1); + }); + + process.on('unhandledRejection', rejection => { + exitWithError(rejection); + }); + + await program.parseAsync(process.argv); +} diff --git a/yarn.lock b/yarn.lock index 98d39db148..d753c5d9cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3030,6 +3030,8 @@ __metadata: "@types/yarnpkg__lockfile": "npm:^1.1.4" "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" + chalk: "npm:^4.0.0" + commander: "npm:^12.0.0" fs-extra: "npm:^11.2.0" semver: "npm:^7.5.3" yaml: "npm:^2.0.0" @@ -27535,7 +27537,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^12.1.0": +"commander@npm:^12.0.0, commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" checksum: 10/cdaeb672d979816853a4eed7f1310a9319e8b976172485c2a6b437ed0db0a389a44cfb222bfbde772781efa9f215bdd1b936f80d6b249485b465c6cb906e1f93