@cyberselab/command 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/dist/app.d.ts +18 -0
- package/dist/app.js +72 -0
- package/dist/arguments.d.ts +6 -0
- package/dist/arguments.js +62 -0
- package/dist/build-info.d.ts +6 -0
- package/dist/build-info.js +3 -0
- package/dist/command.d.ts +19 -0
- package/dist/command.js +19 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +32 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/state.d.ts +8 -0
- package/dist/state.js +6 -0
- package/package.json +48 -4
- package/src/app.ts +79 -0
- package/src/arguments.ts +57 -0
- package/src/build-info.ts +5 -0
- package/src/command.ts +39 -0
- package/src/config.ts +40 -0
- package/src/errors.ts +6 -0
- package/src/state.ts +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cyberselab/command
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
对 Payboy 中 Commander 使用范式的封装与约束。项目继续拥有业务命令,包提供 `app → command → action` 的组织方式、延迟加载、配置适配和执行入口。使用原生 Commander,不引入另一套命令树描述语言。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// cli/commands/serve.command.ts
|
|
7
|
+
import { defineCommand } from "@cyberselab/command/command";
|
|
8
|
+
|
|
9
|
+
export const useServeCommand = defineCommand({
|
|
10
|
+
name: "serve",
|
|
11
|
+
description: "Start server",
|
|
12
|
+
loadAction: async () => (await import("../actions/serve.action.js")).serveAction,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// cli/app.ts
|
|
16
|
+
import { createCliApp } from "@cyberselab/command/app";
|
|
17
|
+
const app = createCliApp({
|
|
18
|
+
name: "example",
|
|
19
|
+
description: "Example CLI",
|
|
20
|
+
buildInfo: { version: "0.1.0", branch: "main", commit: "abc" },
|
|
21
|
+
commands: [useServeCommand],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// main.ts
|
|
25
|
+
import { runCli } from "@cyberselab/command/app";
|
|
26
|
+
process.exitCode = await runCli(app);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`defineCommand()` 返回 `useXxxCommand(app)` 注册函数。`configure(command)` 可声明原生 Commander 参数、选项和别名;`loadAction()` 仅在执行该命令时调用,返回 action 函数。action 收到 `{ args, options, command }`,`args` 按 Commander 声明顺序提供,变长参数作为一个数组;`options` 只包含本命令选项。需要祖先选项时使用 `command.optsWithGlobals()`。
|
|
30
|
+
|
|
31
|
+
也可以直接编写 `useXxxCommand(app: Command)`,通过 `command` 子路径导入原生 `Command` 类型。命令注册模块应保持轻量,业务依赖只能在 action 执行时导入。
|
|
32
|
+
|
|
33
|
+
## 配置适配
|
|
34
|
+
|
|
35
|
+
在 action 中调用 `loadCommandConfig(command, options)`,返回类型由 Schema 推导;或者使用 `withConfig(options, action)` 直接适配原生 `.action()`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { withConfig } from "@cyberselab/command/config";
|
|
39
|
+
|
|
40
|
+
app.command("serve").action(withConfig(
|
|
41
|
+
{ appName: "@example/server", schema: serverConfigSchema },
|
|
42
|
+
async ({ config, options, args, command }) => {
|
|
43
|
+
const { start } = await import("./actions/start.js");
|
|
44
|
+
await start(config);
|
|
45
|
+
},
|
|
46
|
+
));
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
example serve --config ./server.toml --set port=4000
|
|
51
|
+
example create tenant --name demo --set database.pool-size=10
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`--config` 与可重复的 `--set path=value` 是保留全局选项,可放在嵌套命令前后;子命令不能重新声明 config / set / help / version 或 `-h` / `-V`。`--set` 保留首个等号之后的完整字符串,支持空值、负数、点号路径和 kebab-case;相同路径最后一次覆盖,标量与对象路径冲突时报错。业务选项不进入配置。`--` 之后保留给 Commander 位置参数。
|
|
55
|
+
|
|
56
|
+
默认严格拒绝未知选项。`createCliApp({ ..., legacyFlags: true })` 显式启用 Payboy 旧 Flag 兼容模式:已声明选项仍由 Commander 消费,剩余长参数作为配置,例如 `--port=4000`、`--no-cache`。配置覆盖优先级为注入 `options.flags` < 旧 Flag < `--set`。兼容模式不能发现未声明长参数的拼写错误;分离式值沿用旧约定,只消费后续不以 `-` 开头的一个 token,负数请用 `--offset=-1`。配置 Flag 建议放在命令后,避免与子命令位置产生歧义。新项目使用严格模式。
|
|
57
|
+
|
|
58
|
+
兼容分流针对常规的声明式选项、别名、短选项组合与嵌套命令。不要将其与 `.passThroughOptions()`、外部可执行子命令或自行设置 `.allowUnknownOption()` 混用。需要任意透传时使用严格模式和 `--`,自行在 action 内处理位置参数。
|
|
59
|
+
|
|
60
|
+
## 执行约定
|
|
61
|
+
|
|
62
|
+
- 每次运行调用工厂创建新实例。`runCli` 接受的 `argv` 仅包含用户参数,不包含运行时或脚本路径;默认在入口读取 `process.argv.slice(2)`。
|
|
63
|
+
- help / version 不加载配置,也不导入业务 action。构建信息由调用方注入,不读取 git、项目 package.json 或固定前缀环境变量。
|
|
64
|
+
- `runCli` 返回退出码:成功、帮助或版本为 0,输入或配置错误为 2,业务执行失败为 1。库不调用 `process.exit()`,也不修改 `process.exitCode`。
|
|
65
|
+
- stderr 输出经过收敛的错误消息,不整体序列化异常及 cause。可直接调用 action 排查业务异常,或在应用层主动记录经脱敏的诊断。资源清理由应用 action 负责。
|
|
66
|
+
- `stdout` / `stderr` 回调可注入,用于捕获框架输出;业务 action 的输出由项目自身管理。
|
|
67
|
+
|
|
68
|
+
子路径:`app`、`command`、`config`、`build-info`、`errors`。Bun >= 1.3.14,ESM,暂无 Node.js 支持承诺。
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { type BuildInfo } from "./build-info.js";
|
|
3
|
+
export interface CliAppOptions {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
buildInfo?: BuildInfo;
|
|
7
|
+
legacyFlags?: boolean;
|
|
8
|
+
commands?: readonly ((app: Command) => unknown)[];
|
|
9
|
+
}
|
|
10
|
+
export declare function createCliApp(options: CliAppOptions): Command;
|
|
11
|
+
export interface RunCliOptions {
|
|
12
|
+
/** User arguments only: no runtime or script path. */
|
|
13
|
+
argv?: readonly string[];
|
|
14
|
+
stdout?: (text: string) => void;
|
|
15
|
+
stderr?: (text: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/** Returns an exit code. The executable owns process.exitCode and resource cleanup. */
|
|
18
|
+
export declare function runCli(app: Command, options?: RunCliOptions): Promise<number>;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Command, CommanderError } from "commander";
|
|
2
|
+
import { ConfigError } from "@cyberselab/config/errors";
|
|
3
|
+
import { formatBuildInfo } from "./build-info.js";
|
|
4
|
+
import { CommandInputError } from "./errors.js";
|
|
5
|
+
import { partitionLegacyArguments } from "./arguments.js";
|
|
6
|
+
import { appStates } from "./state.js";
|
|
7
|
+
export function createCliApp(options) {
|
|
8
|
+
const app = new Command(options.name).description(options.description)
|
|
9
|
+
.option("--config <path>", "Configuration file path")
|
|
10
|
+
.option("--set <path=value>", "Override a configuration field (repeatable)", (value, previous = []) => [...previous, value], []);
|
|
11
|
+
if (options.buildInfo) {
|
|
12
|
+
const identifier = formatBuildInfo(options.buildInfo);
|
|
13
|
+
app.version(identifier).addHelpText("beforeAll", `Build: ${identifier}\n`);
|
|
14
|
+
}
|
|
15
|
+
appStates.set(app, { legacyFlags: options.legacyFlags ?? false, legacyArguments: [], used: false });
|
|
16
|
+
for (const register of options.commands ?? [])
|
|
17
|
+
register(app);
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
20
|
+
function configureTree(command, root, out, err) {
|
|
21
|
+
if (command !== root && command.options.some((option) => ["config", "set", "help", "version"].includes(option.attributeName()) || ["-h", "-V"].includes(option.short ?? ""))) {
|
|
22
|
+
throw new CommandInputError("Subcommands must not redefine reserved CLI options");
|
|
23
|
+
}
|
|
24
|
+
command.exitOverride().configureOutput({
|
|
25
|
+
writeOut: out, writeErr: err,
|
|
26
|
+
// Commander input errors can contain raw values. The runner reports a safe diagnostic.
|
|
27
|
+
outputError: () => { },
|
|
28
|
+
});
|
|
29
|
+
for (const child of command.commands)
|
|
30
|
+
configureTree(child, root, out, err);
|
|
31
|
+
}
|
|
32
|
+
/** Returns an exit code. The executable owns process.exitCode and resource cleanup. */
|
|
33
|
+
export async function runCli(app, options = {}) {
|
|
34
|
+
const out = options.stdout ?? ((text) => { process.stdout.write(text); });
|
|
35
|
+
const err = options.stderr ?? ((text) => { process.stderr.write(text); });
|
|
36
|
+
try {
|
|
37
|
+
const state = appStates.get(app);
|
|
38
|
+
if (!state)
|
|
39
|
+
throw new CommandInputError("Use createCliApp to create the application");
|
|
40
|
+
if (state.used)
|
|
41
|
+
throw new CommandInputError("Create a fresh CLI application for each execution");
|
|
42
|
+
state.used = true;
|
|
43
|
+
configureTree(app, app, out, err);
|
|
44
|
+
let argv = [...(options.argv ?? process.argv.slice(2))];
|
|
45
|
+
if (state.legacyFlags) {
|
|
46
|
+
const partitioned = partitionLegacyArguments(app, argv);
|
|
47
|
+
argv = partitioned.argv;
|
|
48
|
+
state.legacyArguments = partitioned.configurationArgv;
|
|
49
|
+
}
|
|
50
|
+
await app.parseAsync(argv, { from: "user" });
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof CommanderError) {
|
|
55
|
+
if (error.exitCode === 0)
|
|
56
|
+
return 0;
|
|
57
|
+
// Help caused by a missing command is an input error, not an execution failure.
|
|
58
|
+
err("Invalid command line input. Use --help for usage.\n");
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof ConfigError) {
|
|
62
|
+
err(`${error.code}: ${error.message}\n`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof CommandInputError) {
|
|
66
|
+
err(`${error.message}\n`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
err("Command execution failed.\n");
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
/** Partition only unregistered long flags; Commander retains all business input. */
|
|
3
|
+
export declare function partitionLegacyArguments(root: Command, argv: readonly string[]): {
|
|
4
|
+
argv: string[];
|
|
5
|
+
configurationArgv: string[];
|
|
6
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function optionsFor(command) {
|
|
2
|
+
return [...command.options, ...(command.parent ? optionsFor(command.parent) : [])];
|
|
3
|
+
}
|
|
4
|
+
/** Partition only unregistered long flags; Commander retains all business input. */
|
|
5
|
+
export function partitionLegacyArguments(root, argv) {
|
|
6
|
+
const output = [];
|
|
7
|
+
const legacy = [];
|
|
8
|
+
let command = root;
|
|
9
|
+
for (let index = 0; index < argv.length; index++) {
|
|
10
|
+
const token = argv[index];
|
|
11
|
+
if (token === "--") {
|
|
12
|
+
output.push(...argv.slice(index));
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
const options = optionsFor(command);
|
|
16
|
+
const name = token.split("=", 1)[0];
|
|
17
|
+
const option = options.find((candidate) => candidate.long === name || candidate.short === name);
|
|
18
|
+
if (option) {
|
|
19
|
+
output.push(token);
|
|
20
|
+
if (!token.includes("=") && (option.required || option.optional)) {
|
|
21
|
+
const next = argv[index + 1];
|
|
22
|
+
if (next !== undefined && (option.required || !next.startsWith("-")))
|
|
23
|
+
output.push(argv[++index]);
|
|
24
|
+
if (option.variadic) {
|
|
25
|
+
while (argv[index + 1] !== undefined && !argv[index + 1].startsWith("-"))
|
|
26
|
+
output.push(argv[++index]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Preserve short clusters, including attached option values, for Commander.
|
|
32
|
+
if (/^-[^-]/.test(token)) {
|
|
33
|
+
output.push(token);
|
|
34
|
+
for (let letter = 1; letter < token.length; letter++) {
|
|
35
|
+
const short = options.find((candidate) => candidate.short === `-${token[letter]}`);
|
|
36
|
+
if (short?.required || short?.optional) {
|
|
37
|
+
const next = argv[index + 1];
|
|
38
|
+
if (letter === token.length - 1 && next !== undefined && (short.required || !next.startsWith("-")))
|
|
39
|
+
output.push(argv[++index]);
|
|
40
|
+
if (short.variadic) {
|
|
41
|
+
while (argv[index + 1] !== undefined && !argv[index + 1].startsWith("-"))
|
|
42
|
+
output.push(argv[++index]);
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (token.startsWith("--") && !["--help", "--version"].includes(name)) {
|
|
50
|
+
legacy.push(token);
|
|
51
|
+
if (!token.includes("=") && !token.startsWith("--no-") && argv[index + 1] !== undefined && !argv[index + 1].startsWith("-")) {
|
|
52
|
+
legacy.push(argv[++index]);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
output.push(token);
|
|
57
|
+
const child = command.commands.find((candidate) => candidate.name() === token || candidate.aliases().includes(token));
|
|
58
|
+
if (child)
|
|
59
|
+
command = child;
|
|
60
|
+
}
|
|
61
|
+
return { argv: output, configurationArgv: legacy };
|
|
62
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
export type CommandOptions = Record<string, unknown>;
|
|
3
|
+
export interface ActionContext<Options extends CommandOptions = CommandOptions> {
|
|
4
|
+
args: readonly unknown[];
|
|
5
|
+
options: Options;
|
|
6
|
+
command: Command;
|
|
7
|
+
}
|
|
8
|
+
export type CommandAction<Options extends CommandOptions = CommandOptions> = (context: ActionContext<Options>) => unknown | Promise<unknown>;
|
|
9
|
+
export declare function getActionContext<Options extends CommandOptions>(values: readonly unknown[]): ActionContext<Options>;
|
|
10
|
+
export interface CommandDefinition<Options extends CommandOptions = CommandOptions> {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
configure?: (command: Command) => void;
|
|
14
|
+
/** Imported only when the command action actually executes. */
|
|
15
|
+
loadAction: () => Promise<CommandAction<Options>>;
|
|
16
|
+
}
|
|
17
|
+
/** Produces a useXxxCommand(app) registration function without hiding Commander. */
|
|
18
|
+
export declare function defineCommand<Options extends CommandOptions = CommandOptions>(definition: CommandDefinition<Options>): (app: Command) => Command;
|
|
19
|
+
export type { Command };
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
export function getActionContext(values) {
|
|
3
|
+
const command = values.at(-1);
|
|
4
|
+
if (!(command instanceof Command))
|
|
5
|
+
throw new Error("Expected a Commander action invocation");
|
|
6
|
+
return { args: values.slice(0, -2), options: command.opts(), command };
|
|
7
|
+
}
|
|
8
|
+
/** Produces a useXxxCommand(app) registration function without hiding Commander. */
|
|
9
|
+
export function defineCommand(definition) {
|
|
10
|
+
return (app) => {
|
|
11
|
+
const command = app.command(definition.name).description(definition.description);
|
|
12
|
+
definition.configure?.(command);
|
|
13
|
+
command.action(async (...values) => {
|
|
14
|
+
const action = await definition.loadAction();
|
|
15
|
+
await action(getActionContext(values));
|
|
16
|
+
});
|
|
17
|
+
return command;
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type LoadConfigOptions } from "@cyberselab/config/load";
|
|
2
|
+
import { type ActionContext, type CommandOptions } from "./command.js";
|
|
3
|
+
export type ConfigActionContext<T, Options extends CommandOptions = CommandOptions> = ActionContext<Options> & {
|
|
4
|
+
config: T;
|
|
5
|
+
};
|
|
6
|
+
export declare function loadCommandConfig<T>(command: ActionContext["command"], options: LoadConfigOptions<T>): Promise<T>;
|
|
7
|
+
/** Adapter for native Commander .action(), with lazy configuration loading. */
|
|
8
|
+
export declare function withConfig<T, Options extends CommandOptions = CommandOptions>(options: LoadConfigOptions<T>, action: (context: ConfigActionContext<T, Options>) => unknown | Promise<unknown>): (...values: unknown[]) => Promise<void>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { loadConfig } from "@cyberselab/config/load";
|
|
2
|
+
import { ConfigError } from "@cyberselab/config/errors";
|
|
3
|
+
import { readFlagConfig, readSetConfig } from "@cyberselab/config/flags";
|
|
4
|
+
import { mergeConfigRecords } from "@cyberselab/config/record";
|
|
5
|
+
import { getActionContext } from "./command.js";
|
|
6
|
+
import { CommandInputError } from "./errors.js";
|
|
7
|
+
import { appStates, rootCommand } from "./state.js";
|
|
8
|
+
export async function loadCommandConfig(command, options) {
|
|
9
|
+
const root = rootCommand(command);
|
|
10
|
+
const global = root.opts();
|
|
11
|
+
const flags = mergeConfigRecords(mergeConfigRecords(options.flags ?? {}, readFlagConfig(appStates.get(root)?.legacyArguments ?? [])), readSetConfig(global.set ?? []));
|
|
12
|
+
try {
|
|
13
|
+
return await loadConfig({
|
|
14
|
+
...options,
|
|
15
|
+
file: { ...options.file, path: global.config ?? options.file?.path },
|
|
16
|
+
flags,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof ConfigError)
|
|
21
|
+
throw error;
|
|
22
|
+
throw new CommandInputError("Configuration validation failed", error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Adapter for native Commander .action(), with lazy configuration loading. */
|
|
26
|
+
export function withConfig(options, action) {
|
|
27
|
+
return async (...values) => {
|
|
28
|
+
const context = getActionContext(values);
|
|
29
|
+
const config = await loadCommandConfig(context.command, options);
|
|
30
|
+
await action({ ...context, config });
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export interface AppState {
|
|
3
|
+
legacyFlags: boolean;
|
|
4
|
+
legacyArguments: readonly string[];
|
|
5
|
+
used: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare const appStates: WeakMap<Command, AppState>;
|
|
8
|
+
export declare function rootCommand(command: Command): Command;
|
package/dist/state.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyberselab/command",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Payboy conventions for organizing Commander applications, commands and actions",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Cyberselab/cybersemod.git",
|
|
9
|
+
"directory": "packages/command"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"bun": ">=1.3.14 <2"
|
|
13
|
+
},
|
|
14
|
+
"files": ["src", "dist", "README.md"],
|
|
15
|
+
"exports": {
|
|
16
|
+
"./app": {
|
|
17
|
+
"bun": "./src/app.ts",
|
|
18
|
+
"types": "./dist/app.d.ts",
|
|
19
|
+
"default": "./dist/app.js"
|
|
20
|
+
},
|
|
21
|
+
"./command": {
|
|
22
|
+
"bun": "./src/command.ts",
|
|
23
|
+
"types": "./dist/command.d.ts",
|
|
24
|
+
"default": "./dist/command.js"
|
|
25
|
+
},
|
|
26
|
+
"./config": {
|
|
27
|
+
"bun": "./src/config.ts",
|
|
28
|
+
"types": "./dist/config.d.ts",
|
|
29
|
+
"default": "./dist/config.js"
|
|
30
|
+
},
|
|
31
|
+
"./build-info": {
|
|
32
|
+
"bun": "./src/build-info.ts",
|
|
33
|
+
"types": "./dist/build-info.d.ts",
|
|
34
|
+
"default": "./dist/build-info.js"
|
|
35
|
+
},
|
|
36
|
+
"./errors": {
|
|
37
|
+
"bun": "./src/errors.ts",
|
|
38
|
+
"types": "./dist/errors.d.ts",
|
|
39
|
+
"default": "./dist/errors.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"test": "bun test ./test"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@cyberselab/config": "^0.1.0",
|
|
48
|
+
"commander": "^13.1.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command, CommanderError } from "commander";
|
|
2
|
+
import { ConfigError } from "@cyberselab/config/errors";
|
|
3
|
+
import { formatBuildInfo, type BuildInfo } from "./build-info.js";
|
|
4
|
+
import { CommandInputError } from "./errors.js";
|
|
5
|
+
import { partitionLegacyArguments } from "./arguments.js";
|
|
6
|
+
import { appStates } from "./state.js";
|
|
7
|
+
|
|
8
|
+
export interface CliAppOptions {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
buildInfo?: BuildInfo;
|
|
12
|
+
legacyFlags?: boolean;
|
|
13
|
+
commands?: readonly ((app: Command) => unknown)[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createCliApp(options: CliAppOptions): Command {
|
|
17
|
+
const app = new Command(options.name).description(options.description)
|
|
18
|
+
.option("--config <path>", "Configuration file path")
|
|
19
|
+
.option("--set <path=value>", "Override a configuration field (repeatable)",
|
|
20
|
+
(value: string, previous: string[] = []) => [...previous, value], [] as string[]);
|
|
21
|
+
if (options.buildInfo) {
|
|
22
|
+
const identifier = formatBuildInfo(options.buildInfo);
|
|
23
|
+
app.version(identifier).addHelpText("beforeAll", `Build: ${identifier}\n`);
|
|
24
|
+
}
|
|
25
|
+
appStates.set(app, { legacyFlags: options.legacyFlags ?? false, legacyArguments: [], used: false });
|
|
26
|
+
for (const register of options.commands ?? []) register(app);
|
|
27
|
+
return app;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function configureTree(command: Command, root: Command, out: (text: string) => void, err: (text: string) => void): void {
|
|
31
|
+
if (command !== root && command.options.some((option) => ["config", "set", "help", "version"].includes(option.attributeName()) || ["-h", "-V"].includes(option.short ?? ""))) {
|
|
32
|
+
throw new CommandInputError("Subcommands must not redefine reserved CLI options");
|
|
33
|
+
}
|
|
34
|
+
command.exitOverride().configureOutput({
|
|
35
|
+
writeOut: out, writeErr: err,
|
|
36
|
+
// Commander input errors can contain raw values. The runner reports a safe diagnostic.
|
|
37
|
+
outputError: () => {},
|
|
38
|
+
});
|
|
39
|
+
for (const child of command.commands) configureTree(child, root, out, err);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunCliOptions {
|
|
43
|
+
/** User arguments only: no runtime or script path. */
|
|
44
|
+
argv?: readonly string[];
|
|
45
|
+
stdout?: (text: string) => void;
|
|
46
|
+
stderr?: (text: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns an exit code. The executable owns process.exitCode and resource cleanup. */
|
|
50
|
+
export async function runCli(app: Command, options: RunCliOptions = {}): Promise<number> {
|
|
51
|
+
const out = options.stdout ?? ((text: string) => { process.stdout.write(text); });
|
|
52
|
+
const err = options.stderr ?? ((text: string) => { process.stderr.write(text); });
|
|
53
|
+
try {
|
|
54
|
+
const state = appStates.get(app);
|
|
55
|
+
if (!state) throw new CommandInputError("Use createCliApp to create the application");
|
|
56
|
+
if (state.used) throw new CommandInputError("Create a fresh CLI application for each execution");
|
|
57
|
+
state.used = true;
|
|
58
|
+
configureTree(app, app, out, err);
|
|
59
|
+
let argv = [...(options.argv ?? process.argv.slice(2))];
|
|
60
|
+
if (state.legacyFlags) {
|
|
61
|
+
const partitioned = partitionLegacyArguments(app, argv);
|
|
62
|
+
argv = partitioned.argv;
|
|
63
|
+
state.legacyArguments = partitioned.configurationArgv;
|
|
64
|
+
}
|
|
65
|
+
await app.parseAsync(argv, { from: "user" });
|
|
66
|
+
return 0;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof CommanderError) {
|
|
69
|
+
if (error.exitCode === 0) return 0;
|
|
70
|
+
// Help caused by a missing command is an input error, not an execution failure.
|
|
71
|
+
err("Invalid command line input. Use --help for usage.\n");
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof ConfigError) { err(`${error.code}: ${error.message}\n`); return 2; }
|
|
75
|
+
if (error instanceof CommandInputError) { err(`${error.message}\n`); return 2; }
|
|
76
|
+
err("Command execution failed.\n");
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/arguments.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Command, Option } from "commander";
|
|
2
|
+
|
|
3
|
+
function optionsFor(command: Command): Option[] {
|
|
4
|
+
return [...command.options, ...(command.parent ? optionsFor(command.parent) : [])];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Partition only unregistered long flags; Commander retains all business input. */
|
|
8
|
+
export function partitionLegacyArguments(root: Command, argv: readonly string[]): { argv: string[]; configurationArgv: string[] } {
|
|
9
|
+
const output: string[] = [];
|
|
10
|
+
const legacy: string[] = [];
|
|
11
|
+
let command = root;
|
|
12
|
+
for (let index = 0; index < argv.length; index++) {
|
|
13
|
+
const token = argv[index]!;
|
|
14
|
+
if (token === "--") { output.push(...argv.slice(index)); break; }
|
|
15
|
+
const options = optionsFor(command);
|
|
16
|
+
const name = token.split("=", 1)[0]!;
|
|
17
|
+
const option = options.find((candidate) => candidate.long === name || candidate.short === name);
|
|
18
|
+
if (option) {
|
|
19
|
+
output.push(token);
|
|
20
|
+
if (!token.includes("=") && (option.required || option.optional)) {
|
|
21
|
+
const next = argv[index + 1];
|
|
22
|
+
if (next !== undefined && (option.required || !next.startsWith("-"))) output.push(argv[++index]!);
|
|
23
|
+
if (option.variadic) {
|
|
24
|
+
while (argv[index + 1] !== undefined && !argv[index + 1]!.startsWith("-")) output.push(argv[++index]!);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Preserve short clusters, including attached option values, for Commander.
|
|
30
|
+
if (/^-[^-]/.test(token)) {
|
|
31
|
+
output.push(token);
|
|
32
|
+
for (let letter = 1; letter < token.length; letter++) {
|
|
33
|
+
const short = options.find((candidate) => candidate.short === `-${token[letter]}`);
|
|
34
|
+
if (short?.required || short?.optional) {
|
|
35
|
+
const next = argv[index + 1];
|
|
36
|
+
if (letter === token.length - 1 && next !== undefined && (short.required || !next.startsWith("-"))) output.push(argv[++index]!);
|
|
37
|
+
if (short.variadic) {
|
|
38
|
+
while (argv[index + 1] !== undefined && !argv[index + 1]!.startsWith("-")) output.push(argv[++index]!);
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (token.startsWith("--") && !["--help", "--version"].includes(name)) {
|
|
46
|
+
legacy.push(token);
|
|
47
|
+
if (!token.includes("=") && !token.startsWith("--no-") && argv[index + 1] !== undefined && !argv[index + 1]!.startsWith("-")) {
|
|
48
|
+
legacy.push(argv[++index]!);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
output.push(token);
|
|
53
|
+
const child = command.commands.find((candidate) => candidate.name() === token || candidate.aliases().includes(token));
|
|
54
|
+
if (child) command = child;
|
|
55
|
+
}
|
|
56
|
+
return { argv: output, configurationArgv: legacy };
|
|
57
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
export type CommandOptions = Record<string, unknown>;
|
|
4
|
+
export interface ActionContext<Options extends CommandOptions = CommandOptions> {
|
|
5
|
+
args: readonly unknown[];
|
|
6
|
+
options: Options;
|
|
7
|
+
command: Command;
|
|
8
|
+
}
|
|
9
|
+
export type CommandAction<Options extends CommandOptions = CommandOptions> = (context: ActionContext<Options>) => unknown | Promise<unknown>;
|
|
10
|
+
|
|
11
|
+
export function getActionContext<Options extends CommandOptions>(values: readonly unknown[]): ActionContext<Options> {
|
|
12
|
+
const command = values.at(-1);
|
|
13
|
+
if (!(command instanceof Command)) throw new Error("Expected a Commander action invocation");
|
|
14
|
+
return { args: values.slice(0, -2), options: command.opts<Options>(), command };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CommandDefinition<Options extends CommandOptions = CommandOptions> {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
configure?: (command: Command) => void;
|
|
21
|
+
/** Imported only when the command action actually executes. */
|
|
22
|
+
loadAction: () => Promise<CommandAction<Options>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Produces a useXxxCommand(app) registration function without hiding Commander. */
|
|
26
|
+
export function defineCommand<Options extends CommandOptions = CommandOptions>(definition: CommandDefinition<Options>): (app: Command) => Command {
|
|
27
|
+
return (app) => {
|
|
28
|
+
const command = app.command(definition.name).description(definition.description);
|
|
29
|
+
definition.configure?.(command);
|
|
30
|
+
command.action(async (...values: unknown[]) => {
|
|
31
|
+
const action = await definition.loadAction();
|
|
32
|
+
await action(getActionContext<Options>(values));
|
|
33
|
+
});
|
|
34
|
+
return command;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The native type is intentionally available for project-specific useXxxCommand functions.
|
|
39
|
+
export type { Command };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { loadConfig, type LoadConfigOptions } from "@cyberselab/config/load";
|
|
2
|
+
import { ConfigError } from "@cyberselab/config/errors";
|
|
3
|
+
import { readFlagConfig, readSetConfig } from "@cyberselab/config/flags";
|
|
4
|
+
import { mergeConfigRecords } from "@cyberselab/config/record";
|
|
5
|
+
import { getActionContext, type ActionContext, type CommandOptions } from "./command.js";
|
|
6
|
+
import { CommandInputError } from "./errors.js";
|
|
7
|
+
import { appStates, rootCommand } from "./state.js";
|
|
8
|
+
|
|
9
|
+
export type ConfigActionContext<T, Options extends CommandOptions = CommandOptions> = ActionContext<Options> & { config: T };
|
|
10
|
+
|
|
11
|
+
export async function loadCommandConfig<T>(command: ActionContext["command"], options: LoadConfigOptions<T>): Promise<T> {
|
|
12
|
+
const root = rootCommand(command);
|
|
13
|
+
const global = root.opts<{ config?: string; set?: string[] }>();
|
|
14
|
+
const flags = mergeConfigRecords(
|
|
15
|
+
mergeConfigRecords(options.flags ?? {}, readFlagConfig(appStates.get(root)?.legacyArguments ?? [])),
|
|
16
|
+
readSetConfig(global.set ?? []),
|
|
17
|
+
);
|
|
18
|
+
try {
|
|
19
|
+
return await loadConfig({
|
|
20
|
+
...options,
|
|
21
|
+
file: { ...options.file, path: global.config ?? options.file?.path },
|
|
22
|
+
flags,
|
|
23
|
+
});
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof ConfigError) throw error;
|
|
26
|
+
throw new CommandInputError("Configuration validation failed", error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Adapter for native Commander .action(), with lazy configuration loading. */
|
|
31
|
+
export function withConfig<T, Options extends CommandOptions = CommandOptions>(
|
|
32
|
+
options: LoadConfigOptions<T>,
|
|
33
|
+
action: (context: ConfigActionContext<T, Options>) => unknown | Promise<unknown>,
|
|
34
|
+
): (...values: unknown[]) => Promise<void> {
|
|
35
|
+
return async (...values) => {
|
|
36
|
+
const context = getActionContext<Options>(values);
|
|
37
|
+
const config = await loadCommandConfig(context.command, options);
|
|
38
|
+
await action({ ...context, config });
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/errors.ts
ADDED
package/src/state.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
export interface AppState { legacyFlags: boolean; legacyArguments: readonly string[]; used: boolean }
|
|
4
|
+
export const appStates = new WeakMap<Command, AppState>();
|
|
5
|
+
|
|
6
|
+
export function rootCommand(command: Command): Command {
|
|
7
|
+
while (command.parent !== null) command = command.parent;
|
|
8
|
+
return command;
|
|
9
|
+
}
|