From 80b784a9f35ba841b689e3bb72d45b0ba38923c3 Mon Sep 17 00:00:00 2001 From: chenyunda218 Date: Fri, 4 Sep 2026 20:26:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E5=AF=A6=E4=BD=9C=20CLI=20?= =?UTF-8?q?=E5=90=84=E6=8C=87=E4=BB=A4=E8=88=87=20help=EF=BC=88M1+M2?= =?UTF-8?q?=EF=BC=8Cissue=20#13=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加入 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) --- README.md | 23 ++++++-- package.json | 22 ++++++++ src/cli.ts | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ src/command.ts | 89 ++++++++++++++++++++++++++++++ src/commands.ts | 102 +++++++++++++++++++++++++++++++++++ src/config.ts | 53 ++++++++++++++++++ src/errors.ts | 22 ++++++++ src/http.ts | 81 ++++++++++++++++++++++++++++ tsconfig.json | 20 +++++++ 9 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/command.ts create mode 100644 src/commands.ts create mode 100644 src/config.ts create mode 100644 src/errors.ts create mode 100644 src/http.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 3d1b18d..d0ec0e9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fox CLI — 與 Fox 後端互動的命令列工具 -> 專案狀態:規劃中(本目錄與說明先行,見 issue #10)|日期:2026-09-04 +> 專案狀態:M0–M2 已實作(M1:HTTP client、設定讀取、`tokens verify`/`auth status`;M2:`tokens create/list/revoke`、公開的 `novels get`)|日期:2026-09-04 `fox` 是一個命令列工具,讓人與 **AI agent** 不經過前端,直接與 Fox 後端 API 互動:查詢/建立/編輯作品、管理 access token 等。目標是「一句命令拿到機器可解析的結果」,方便腳本、CI 與 agent 排程使用。 @@ -54,11 +54,26 @@ 里程碑: -- **M0**:子項目目錄+README(本步)。 -- **M1**:HTTP client、設定讀取、`tokens verify`/`auth status`。 -- **M2**:`tokens create/list/revoke`、公開的 `novels get`。 +- **M0**:子項目目錄+README(完成)。 +- **M1**:HTTP client、設定讀取、`tokens verify`/`auth status`(完成,見 `src/http.ts`、`src/config.ts`)。 +- **M2**:`tokens create/list/revoke`、公開的 `novels get`(完成)。 - **M3**:`novels list/create/update`(視後端授權進度)、`--pretty`、錯誤處理打磨。 +## 開發 + +```bash +pnpm install # 倉庫根目錄 +pnpm --filter fox-cli build +pnpm --filter fox-cli start -- <子命令> # 例如 auth status +``` + +結構:`src/cli.ts`(進入點與分發)、`src/command.ts`(參數解析與註冊表)、 +`src/config.ts`(設定讀取)、`src/http.ts`(fetch 封裝)、`src/commands.ts`(各命令實作)、 +`src/errors.ts`(錯誤與 exit code)。 + +exit code:`0` 成功;`1` 執行失敗(後端 4xx/5xx、網路錯誤);`2` 用法錯誤。 +`tokens revoke` 成功時輸出 `{"ok": true, "id": }`(後端回 204 無內容)。 + ## 維護說明 1. 後端端點異動時,同步更新第三節對應表。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..3481bb9 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "fox-cli", + "version": "0.1.0", + "private": true, + "description": "Fox(狐)命令列工具:直接與 Fox 後端 API 互動(供人與 AI agent 使用)", + "type": "module", + "bin": { + "fox": "./dist/cli.js" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/cli.js", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..ef56422 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * fox — Fox 線上小說平台 CLI。 + * + * 設計原則(見 cli/README.md): + * - 非互動:缺參數直接報錯,不跳 prompt。 + * - 機器可讀:資料輸出為單一 JSON(stdout),其餘訊息走 stderr。 + * - exit code:0 成功;1 執行失敗(後端/網路);2 用法錯誤。 + * - secret 不落檔:token 只從環境變數、--token 或 ~/.config/fox/cli.json 讀取。 + */ + +import { loadConfig } from "./config.js"; +import { ApiError, UsageError } from "./errors.js"; +import { parseArgs, Registry } from "./command.js"; +import { registerAuthCommands, registerNovelCommands, registerTokenCommands } from "./commands.js"; + +const PROGRAM = "fox"; + +const ROOT_HELP = `fox — 與 Fox 後端互動的命令列工具 + +用法:fox <命令> [參數] +資料輸出為單一 JSON(stdout);錯誤訊息走 stderr。 +exit code:0 成功;1 執行失敗(後端 4xx/5xx、網路錯誤);2 用法錯誤。 + +命令: + fox auth status 查詢登入/SSO 設定(公開) + fox tokens verify 驗證手中 token 是否有效 + fox tokens create --name <名稱> 建立新 token(原始值只顯示一次) + [--expires-at ] + fox tokens list 列出 token(不含 hash/原始值) + fox tokens revoke 撤銷 token(冪等) + fox novels get 查單一作品(公開) + +全域選項(放在子命令後): + --api-url 後端 API 位址(預設 FOX_API_URL 或 http://localhost:3000) + --token access token(會留在 shell 歷史,建議改用環境變數) + --help 顯示說明 + +設定優先順序:--token/--api-url > FOX_API_TOKEN/FOX_API_URL > +~/.config/fox/cli.json({"apiUrl": "...", "token": "..."},權限建議 600)。 + +尚未支援(見 cli/README.md 授權缺口):novels list/create/update — +後端 novels 授權端點目前只吃瀏覽器 session,待後端接受 Bearer access token 後補齊。 + +每個命令可用 --help 查看詳細說明,例如:fox tokens create --help`; + +function buildRegistry(): Registry { + const registry = new Registry(); + registerAuthCommands(registry); + registerTokenCommands(registry); + registerNovelCommands(registry); + return registry; +} + +function printHelp(lines: string): void { + process.stderr.write(lines + "\n"); +} + +async function main(argv: string[]): Promise { + const args = parseArgs(argv); + const wantsHelp = args.flags["help"] === true || args.positionals[0] === "help"; + + const registry = buildRegistry(); + + // 位置參數中的子命令路徑(過濾掉 "help") + const path = args.positionals.filter((p) => p !== "help"); + + if (path.length === 0) { + printHelp(ROOT_HELP); + return wantsHelp ? 0 : 2; + } + + // 以最長前綴匹配命令:前綴是命令路徑,其餘位置參數才是命令自己的參數。 + // 例如 "tokens revoke abc" → 命令 ["tokens","revoke"]、參數 ["abc"](→ 用法錯誤)。 + let command: ReturnType = undefined; + let matchedLength = 0; + for (let len = path.length; len >= 1; len -= 1) { + const candidate = registry.find(path.slice(0, len)); + if (candidate) { + command = candidate; + matchedLength = len; + break; + } + } + + if (!command) { + // 可能是群組(fox tokens / fox novels)或未知命令 + const group = registry.children(path); + if (group.length > 0) { + const lines = [ + `fox ${path.join(" ")} — 可用子命令:`, + ...group.map((c) => ` ${c.usage.padEnd(52)} ${c.summary}`), + "", + `詳細說明:fox ${path.join(" ")} <子命令> --help`, + ]; + printHelp(lines.join("\n")); + return wantsHelp ? 0 : 2; + } + const known = registry + .all() + .map((c) => c.usage) + .join("\n "); + process.stderr.write( + `未知命令:fox ${path.join(" ")}\n可用命令:\n ${known}\n\n${ROOT_HELP}\n`, + ); + return 2; + } + + if (wantsHelp) { + printHelp(`${command.usage}\n ${command.summary}\n\n全域選項:--api-url 、--token 、--help`); + return 0; + } + + const ctx = { + args: { positionals: args.positionals.slice(matchedLength), flags: args.flags }, + print: (value: unknown) => { + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); + }, + }; + await command.run(ctx); + return 0; +} + +main(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((err: unknown) => { + if (err instanceof UsageError) { + process.stderr.write(`用法錯誤:${err.message}\n`); + process.exitCode = 2; + } else if (err instanceof ApiError) { + const status = err.status ? `(HTTP ${err.status})` : ""; + process.stderr.write(`錯誤${status}:${err.message}\n`); + process.exitCode = 1; + } else { + process.stderr.write(`未預期的錯誤:${(err as Error).stack ?? err}\n`); + process.exitCode = 1; + } + }); diff --git a/src/command.ts b/src/command.ts new file mode 100644 index 0000000..0217b0b --- /dev/null +++ b/src/command.ts @@ -0,0 +1,89 @@ +/** 命令定義:名稱、說明、參數與執行。供 help 與分發使用。 */ + +export interface ParsedArgs { + /** 位置參數(子命令後的非選項值)。 */ + positionals: string[]; + /** 選項旗標(--key value 或 --flag)。 */ + flags: Record; +} + +export interface CommandContext { + args: ParsedArgs; + /** 輸出 JSON 到 stdout(唯一 stdout 內容)。 */ + print: (value: unknown) => void; +} + +export interface Command { + /** 子命令路徑,例如 ["tokens", "verify"]。 */ + path: string[]; + summary: string; + usage: string; + /** 位置參數說明,例如 [""]。與 usage 一致即可。 */ + run: (ctx: CommandContext) => Promise | void; +} + +/** 解析 argv:--key value、--key=value、--flag(布林)、-- 結束選項。 */ +export function parseArgs(argv: string[]): ParsedArgs { + const positionals: string[] = []; + const flags: Record = {}; + 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, key: string): string | undefined { + const v = flags[key]; + return typeof v === "string" ? v : undefined; +} + +/** 註冊表:以完整路徑字串("tokens verify")索引。 */ +export class Registry { + private readonly commands = new Map(); + + 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()); + } +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..bcf3e30 --- /dev/null +++ b/src/commands.ts @@ -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 { + 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 ]", + run: async (ctx) => { + const name = flagString(ctx.args.flags, "name"); + if (!name || !name.trim()) { + throw new UsageError("缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at ]"); + } + 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 = { 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 ", + 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 "); + } + 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 ", + run: async (ctx) => { + const slug = ctx.args.positionals[0]; + if (!slug) throw new UsageError("缺少 ;用法:fox novels get "); + await call(ctx, `novels/${encodeURIComponent(slug)}`); + }, + }); +} + +/** 共用型別:命令實作可引用的介面(保持單一來源)。 */ +export type { Command }; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..846c429 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,53 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { ApiError } from "./errors.js"; + +/** CLI 設定:API 位址與 access token。 */ +export interface FoxConfig { + apiUrl: string; + token: string | null; +} + +const DEFAULT_API_URL = "http://localhost:3000"; + +/** + * 讀取設定。優先順序(高 → 低): + * 1. 環境變數 FOX_API_URL / FOX_API_TOKEN + * 2. --api-url / --token 參數(由 caller 傳入) + * 3. 本機設定檔 ~/.config/fox/cli.json({"apiUrl": "...", "token": "..."}) + * + * apiUrl 預設 http://localhost:3000;token 找不到時為 null, + * 需要 token 的命令由 caller 檢查並報錯。 + */ +export async function loadConfig(flags: { apiUrl?: string; token?: string }): Promise { + const configPath = join(homedir(), ".config", "fox", "cli.json"); + let fileApiUrl: string | undefined; + let fileToken: string | undefined; + try { + const raw = await readFile(configPath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + if (typeof obj.apiUrl === "string") fileApiUrl = obj.apiUrl; + if (typeof obj.token === "string") fileToken = obj.token; + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + // 設定檔存在但壞掉:明確報錯,而不是靜默忽略。 + throw new ApiError(`設定檔 ${configPath} 無法解析:${(err as Error).message}`); + } + } + + const envApiUrl = process.env.FOX_API_URL?.trim() || undefined; + const envToken = process.env.FOX_API_TOKEN?.trim() || undefined; + + const apiUrl = + flags.apiUrl ?? + envApiUrl ?? + fileApiUrl ?? + DEFAULT_API_URL; + const token = flags.token ?? envToken ?? fileToken ?? null; + return { apiUrl: apiUrl.replace(/\/+$/, ""), token }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..532986c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,22 @@ +/** + * 執行期間錯誤:後端 4xx/5xx、網路錯誤等。 + * exit code 1;用法錯誤(UsageError)exit code 2。 + */ +export class ApiError extends Error { + constructor( + message: string, + readonly status?: number, + readonly body?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** 用法錯誤:缺參數、未知子命令、參數格式不符。exit code 2。 */ +export class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..309d0f3 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,81 @@ +import { ApiError } from "./errors.js"; +import type { FoxConfig } from "./config.js"; + +export interface RequestOptions { + method?: string; + body?: unknown; + /** 此請求需要 Bearer token(缺 token 時報錯)。 */ + auth?: boolean; +} + +/** + * 對 Fox 後端發起 HTTP 請求。 + * + * - 成功(2xx):解析後的 JSON 回應;204 回 null。 + * - 非 2xx:丟 ApiError(帶 status 與後端訊息)。 + * - 網路錯誤:丟 ApiError。 + */ +export async function request( + config: FoxConfig, + path: string, + options: RequestOptions = {}, +): Promise { + const { method = "GET", body, auth = false } = options; + const headers: Record = { Accept: "application/json" }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (auth) { + if (!config.token) { + throw new ApiError( + "缺少 access token:請設定 FOX_API_TOKEN 環境變數、--token 參數,或 ~/.config/fox/cli.json(見 fox --help)", + ); + } + headers.Authorization = `Bearer ${config.token}`; + } + + let res: Response; + try { + res = await fetch(new URL(path, `${config.apiUrl}/`), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "manual", + }); + } catch (err) { + throw new ApiError(`無法連線到 ${config.apiUrl}:${(err as Error).message}`); + } + + if (res.status === 204) { + await res.body?.cancel(); + return null; + } + + let data: unknown = null; + const text = await res.text(); + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + if (!res.ok) { + const message = extractMessage(data) ?? `後端回應 ${res.status}`; + throw new ApiError(message, res.status, data); + } + return data; +} + +/** 從 NestJS 錯誤回應({message: string | string[]}抽取人類可讀訊息。 */ +function extractMessage(data: unknown): string | null { + if (data && typeof data === "object" && !Array.isArray(data)) { + const message = (data as Record).message; + if (typeof message === "string") return message; + if (Array.isArray(message)) { + const parts = message.filter((p): p is string => typeof p === "string"); + if (parts.length > 0) return parts.join(";"); + } + } + if (typeof data === "string" && data) return data; + return null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..32a4b01 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2023"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}