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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user