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
+102
View File
@@ -0,0 +1,102 @@
import { UsageError } from "./errors.js";
import type { Command, CommandContext, Registry } from "./command.js";
import { flagString } from "./command.js";
import { loadConfig } from "./config.js";
import { request } from "./http.js";
/** 共用:載入設定並執行一次 API 請求,把結果印成 JSON。 */
async function call(
ctx: CommandContext,
path: string,
options: { method?: string; body?: unknown; auth?: boolean; print?: boolean } = {},
): Promise<unknown> {
const { print = true } = options;
const config = await loadConfig({
apiUrl: flagString(ctx.args.flags, "api-url"),
token: flagString(ctx.args.flags, "token"),
});
const data = await request(config, path, options);
if (print) ctx.print(data);
return data;
}
export function registerAuthCommands(registry: Registry): void {
registry.add({
path: ["auth", "status"],
summary: "查詢登入/SSO 設定(公開端點)",
usage: "fox auth status",
run: async (ctx) => {
await call(ctx, "auth/status");
},
});
}
export function registerTokenCommands(registry: Registry): void {
registry.add({
path: ["tokens", "verify"],
summary: "驗證手中 token 是否有效(無效回非零 exit code)",
usage: "fox tokens verify",
run: async (ctx) => {
await call(ctx, "access-tokens/verify", { auth: true });
},
});
registry.add({
path: ["tokens", "create"],
summary: "建立新 token(原始值只在這次輸出顯示)",
usage: "fox tokens create --name <名稱> [--expires-at <ISO8601>]",
run: async (ctx) => {
const name = flagString(ctx.args.flags, "name");
if (!name || !name.trim()) {
throw new UsageError("缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at <ISO8601>]");
}
const expiresAt = flagString(ctx.args.flags, "expires-at");
if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) {
throw new UsageError(`--expires-at 不是有效的 ISO 8601 日期:${expiresAt}`);
}
const body: Record<string, string> = { name: name.trim() };
if (expiresAt !== undefined) body.expiresAt = new Date(expiresAt).toISOString();
await call(ctx, "access-tokens", { method: "POST", body });
},
});
registry.add({
path: ["tokens", "list"],
summary: "列出 token(不含 hash/原始值)",
usage: "fox tokens list",
run: async (ctx) => {
await call(ctx, "access-tokens");
},
});
registry.add({
path: ["tokens", "revoke"],
summary: "撤銷 token(冪等)",
usage: "fox tokens revoke <id>",
run: async (ctx) => {
const id = ctx.args.positionals[0];
const n = Number(id);
if (id === undefined || !/^\d+$/.test(id) || !Number.isSafeInteger(n)) {
throw new UsageError("缺少或無效的 token id;用法:fox tokens revoke <id>");
}
await call(ctx, `access-tokens/${n}`, { method: "DELETE", print: false });
ctx.print({ ok: true, id: n });
},
});
}
export function registerNovelCommands(registry: Registry): void {
registry.add({
path: ["novels", "get"],
summary: "查單一作品(公開端點)",
usage: "fox novels get <slug>",
run: async (ctx) => {
const slug = ctx.args.positionals[0];
if (!slug) throw new UsageError("缺少 <slug>;用法:fox novels get <slug>");
await call(ctx, `novels/${encodeURIComponent(slug)}`);
},
});
}
/** 共用型別:命令實作可引用的介面(保持單一來源)。 */
export type { Command };