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:
+81
@@ -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<unknown> {
|
||||
const { method = "GET", body, auth = false } = options;
|
||||
const headers: Record<string, string> = { 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<string, unknown>).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;
|
||||
}
|
||||
Reference in New Issue
Block a user