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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-13 15:40:15 +01:00
parent a151ad0814
commit c95a130f52
5 changed files with 245 additions and 1 deletions
+2
View File
@@ -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",
+7
View File
@@ -253,6 +253,13 @@ export class PackageRoles {
static getRoleInfo(role: string): PackageRoleInfo;
}
// @public
export function runCliModule(options: {
module: CliModule;
name: string;
version?: string;
}): Promise<void>;
// @public
export function runConcurrentTasks<TItem>(
options: ConcurrentTasksOptions<TItem>,
@@ -15,4 +15,5 @@
*/
export { createCliModule } from './createCliModule';
export { runCliModule } from './runCliModule';
export type { CliCommand, CliCommandContext, CliModule } from './types';
@@ -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<CliCommand>): 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<void> {
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);
}
+3 -1
View File
@@ -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