Files
modelstudioai__cli/packages/runtime/src/create-cli.ts
T
若麒 7a0a083b2e refactor(core): replace skipDefaultApiKeySetup with required auth field
Commands now declare their credential requirement explicitly via a
required `auth: "apiKey" | "console" | "none"` field instead of the
boolean `skipDefaultApiKeySetup`. The runtime prepares credentials
based on this declaration and skips API-key setup under --dry-run.

- core: add AuthRequirement type and required `auth` field to
  Command/CommandSpec; drop skipDefaultApiKeySetup
- runtime: gate API-key setup on `auth === "apiKey" && !dryRun`
- commands: annotate all 45 commands (apiKey 25 / console 11 / none 9)
2026-06-24 16:41:09 +08:00

175 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { scanCommandPath, parseFlags } from "./args.ts";
import { CommandRegistry } from "./registry.ts";
import type { Command } from "bailian-cli-core";
import {
GLOBAL_OPTIONS,
loadConfig,
resolveCredential,
trackCommandExecution,
flushTelemetry,
} from "bailian-cli-core";
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { registerCommandHelpPrinter, setExecutingCommandPath } from "./utils/command-help.ts";
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
export interface CliOptions {
/** Binary name shown in help/usage/version output (e.g. "bl", "rag"). */
binName: string;
/** Product version for `--version` output, telemetry and update checks. */
version: string;
/** Telemetry client name (e.g. "bailian-cli", "rag-cli"). Defaults to `binName`. */
clientName?: string;
/** npm package name for self-update (e.g. "bailian-cli", "bailian-cli-rag"). */
npmPackage: string;
}
export interface Cli {
run(argv?: string[]): Promise<void>;
}
/**
* Build a CLI from an injected command set. The runtime is agnostic to *which*
* commands exist — each product (bailian-cli, rag-cli, …) passes its own map and
* identity. No module-level singleton: the registry is scoped to this instance.
*/
export function createCli(commands: Record<string, Command>, opts: CliOptions): Cli {
const registry = new CommandRegistry(commands, opts.binName);
const clientName = opts.clientName ?? opts.binName;
const npmPackage = opts.npmPackage;
const version = opts.version;
// 必须在任何 fetch 发起前安装(含 update-checker / telemetry)
try {
setupProxyFromEnv();
} catch (err) {
handleError(err, opts.binName);
}
registerCommandHelpPrinter((commandPath, out) => {
registry.printHelp(commandPath, out);
});
// 优雅处理 Ctrl+C
// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会
// 落网络;flush 与较短超时 race,保证 SIGINT 仍然响应及时。
process.on("SIGINT", () => {
process.stderr.write("\nInterrupted. Exiting.\n");
void flushTelemetry(500).finally(() => process.exit(130));
});
// 优雅处理 stdout EPIPE(例如管道到提前退出的 `mpv`)
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
if (e.code === "EPIPE") process.exit(0);
else throw e;
});
async function main(): Promise<void> {
let argv = process.argv.slice(2);
if (argv[0] === "--") argv = argv.slice(1);
if (argv.includes("--version") || argv.includes("-v")) {
process.stdout.write(`${opts.binName} ${version}\n`);
process.exit(0);
}
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
if (argv.includes("--help") || argv.includes("-h")) {
registry.printHelp(commandPath, process.stderr);
process.exit(0);
}
// 未传任何命令:展示帮助信息与登录引导
if (commandPath.length === 0) {
registry.printHelp([], process.stderr);
const flags = parseFlags(argv, GLOBAL_OPTIONS);
const config = loadConfig(flags);
config.clientName = clientName;
config.clientVersion = version;
config.binName = opts.binName;
config.npmPackage = npmPackage;
const hasKey = !!(
config.apiKey ||
config.fileApiKey ||
config.fileAccessToken ||
config.accessTokenEnv
);
if (hasKey) printQuickStart();
else printWelcomeBanner(opts.binName);
process.exit(0);
}
// 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出
if (registry.isGroupPath(commandPath)) {
registry.printHelp(commandPath, process.stderr);
process.exit(0);
}
const { command, extra } = registry.resolve(commandPath);
const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]);
if (extra.length > 0) (flags as Record<string, unknown>)._positional = extra;
const config = loadConfig(flags);
config.clientName = clientName;
config.clientVersion = version;
config.binName = opts.binName;
config.npmPackage = npmPackage;
// 仅 apiKey 类命令由框架统一准备 API Key;dry-run 时跳过(只打印请求,不要求凭证)。
// console 命令在命令体内解析 Console Gateway 凭证;none 命令无需凭证。
if (command.auth === "apiKey" && !config.dryRun) {
await ensureApiKey(config);
try {
const credential = await resolveCredential(config);
maybeShowStatusBar(config, credential.token, credential);
} catch {
/* 没有凭证,不展示状态栏 */
}
}
const updateCheckPromise = checkForUpdate(version, npmPackage).catch(() => {});
setExecutingCommandPath(commandPath);
await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags));
await updateCheckPromise;
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${version} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}${opts.binName} update${reset} to upgrade\n\n`);
}
// 进程退出前尽力等待在途的埋点完成。
// 使用较短超时兜底,避免慢网拖慢用户感知。
await flushTelemetry(1000);
}
return {
run() {
return main().catch((err) => {
// 在 handleError() 调用 process.exit() 之前刷出在途埋点。
// 命令抛出的错误已被 trackCommandExecution 的 finally 块记录,
// 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话,
// 错误事件会随进程退出丢掉。
return flushTelemetry(1000).finally(() =>
handleError(err, opts.binName),
) as unknown as void;
});
},
};
}