feat(cli): 實作 CLI 各指令與 help(M1+M2,issue #13)

- 加入 pnpm workspace;Node.js ≥22 + TypeScript 實作
- 指令:auth status、tokens verify/create/list/revoke、novels get
- 非互動、stdout 僅輸出單一 JSON;exit code 0/1/2
- 設定優先順序:--token/--api-url > FOX_API_TOKEN/FOX_API_URL > ~/.config/fox/cli.json
- novels list/create/update 待後端接受 Bearer token 後補齊(README 授權缺口)
- 已對模擬後端完成端到端驗證(含 401/404/網路錯誤、冪等 revoke)
This commit is contained in:
2026-09-04 20:26:11 +08:00
parent 1686322eff
commit 80b784a9f3
9 changed files with 548 additions and 4 deletions
+89
View File
@@ -0,0 +1,89 @@
/** 命令定義:名稱、說明、參數與執行。供 help 與分發使用。 */
export interface ParsedArgs {
/** 位置參數(子命令後的非選項值)。 */
positionals: string[];
/** 選項旗標(--key value 或 --flag)。 */
flags: Record<string, string | boolean>;
}
export interface CommandContext {
args: ParsedArgs;
/** 輸出 JSON 到 stdout(唯一 stdout 內容)。 */
print: (value: unknown) => void;
}
export interface Command {
/** 子命令路徑,例如 ["tokens", "verify"]。 */
path: string[];
summary: string;
usage: string;
/** 位置參數說明,例如 ["<slug>"]。與 usage 一致即可。 */
run: (ctx: CommandContext) => Promise<void> | void;
}
/** 解析 argv:--key value、--key=value、--flag(布林)、-- 結束選項。 */
export function parseArgs(argv: string[]): ParsedArgs {
const positionals: string[] = [];
const flags: Record<string, string | boolean> = {};
let i = 0;
let noMoreFlags = false;
while (i < argv.length) {
const arg = argv[i++]!;
if (!noMoreFlags && arg === "--") {
noMoreFlags = true;
continue;
}
if (!noMoreFlags && arg.startsWith("--")) {
const eq = arg.indexOf("=");
if (eq !== -1) {
const key = arg.slice(2, eq);
const value = arg.slice(eq + 1);
flags[key] = value;
} else {
const key = arg.slice(2);
const next = argv[i];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
i += 1;
} else {
flags[key] = true;
}
}
continue;
}
positionals.push(arg);
}
return { positionals, flags };
}
export function flagString(flags: Record<string, string | boolean>, key: string): string | undefined {
const v = flags[key];
return typeof v === "string" ? v : undefined;
}
/** 註冊表:以完整路徑字串("tokens verify")索引。 */
export class Registry {
private readonly commands = new Map<string, Command>();
add(command: Command): void {
this.commands.set(command.path.join(" "), command);
}
/** 找出命令;prefix 可指向群組(回傳其子命令列表供 help)。 */
find(prefix: string[]): Command | undefined {
return this.commands.get(prefix.join(" "));
}
/** 列出 prefix 下的下一層子命令(供群組 help)。 */
children(prefix: string[]): Command[] {
return Array.from(this.commands.values()).filter((c) => {
if (c.path.length !== prefix.length + 1) return false;
return prefix.every((p, idx) => c.path[idx] === p);
});
}
all(): Command[] {
return Array.from(this.commands.values());
}
}