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:
@@ -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": <id>}`(後端回 204 無內容)。
|
||||
|
||||
## 維護說明
|
||||
|
||||
1. 後端端點異動時,同步更新第三節對應表。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+140
@@ -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 <ISO8601>]
|
||||
fox tokens list 列出 token(不含 hash/原始值)
|
||||
fox tokens revoke <id> 撤銷 token(冪等)
|
||||
fox novels get <slug> 查單一作品(公開)
|
||||
|
||||
全域選項(放在子命令後):
|
||||
--api-url <URL> 後端 API 位址(預設 FOX_API_URL 或 http://localhost:3000)
|
||||
--token <value> 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<number> {
|
||||
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<Registry["find"]> = 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 <URL>、--token <value>、--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;
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+102
@@ -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 };
|
||||
@@ -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<FoxConfig> {
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user