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; } /** * 讀取內容類選項(--content/--prompt/--previous-context)的值。 * 值為 "-" 時改從 stdin 讀入全部內容(適合多行文字經管線傳入); * stdin 是終端機時直接報用法錯誤,避免掛起等待輸入。 */ async function readValue( flagName: string, value: string | undefined, ): Promise { if (value !== "-") return value; if (process.stdin.isTTY) { throw new UsageError( `--${flagName} - 需經管線傳入內容(stdin 是終端機,會無限等待);用法:cat 檔案 | fox … --${flagName} -`, ); } const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk as Buffer); } return Buffer.concat(chunks).toString("utf8"); } /** 讀取位置參數並要求非空。 */ function requirePositional( positionals: string[], index: number, name: string, usage: string, ): string { const value = positionals[index]; if (value === undefined || !value.trim()) { throw new UsageError(`缺少 <${name}>;用法:${usage}`); } return value.trim(); } /** 讀取必填的字串選項。 */ async function requireFlag( flags: Record, flagName: string, usage: string, ): Promise { const value = await readValue(flagName, flagString(flags, flagName)); if (value === undefined || !value.trim()) { throw new UsageError(`缺少 --${flagName};用法:${usage}`); } return value.trim(); } 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 ] [--scopes read,write,generate]", run: async (ctx) => { const name = flagString(ctx.args.flags, "name"); if (!name || !name.trim()) { throw new UsageError( "缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at ] [--scopes read,write,generate]", ); } 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(); const scopes = flagString(ctx.args.flags, "scopes"); if (scopes !== undefined) { const list = scopes .split(",") .map((s) => s.trim()) .filter(Boolean); const valid = ["read", "write", "generate"]; const bad = list.filter((s) => !valid.includes(s)); if (bad.length > 0) { throw new UsageError( `--scopes 含無效值:${bad.join(",")}(可用:read、write、generate)`, ); } body.scopes = list; } 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 }); }, }); } const CHAPTER_USAGE = "fox chapters …(見 fox chapters --help)"; const CHAPTER_STATUSES = ["draft", "published", "hidden"] as const; const NOVEL_STATUSES = ["draft", "serializing", "completed", "hidden"] as const; /** 讀取 --status 並驗證值域(draft/published/hidden)。 */ function readChapterStatusFlag(flags: Record): string | undefined { const value = flagString(flags, "status"); if (value === undefined) return undefined; if (!CHAPTER_STATUSES.includes(value as (typeof CHAPTER_STATUSES)[number])) { throw new UsageError(`--status 不正確(${CHAPTER_STATUSES.join("|")}):${value}`); } return value; } /** 讀取 --status 並驗證值域(draft/serializing/completed/hidden)。 */ function readNovelStatusFlag(flags: Record): string | undefined { const value = flagString(flags, "status"); if (value === undefined) return undefined; if (!NOVEL_STATUSES.includes(value as (typeof NOVEL_STATUSES)[number])) { throw new UsageError(`--status 不正確(${NOVEL_STATUSES.join("|")}):${value}`); } return value; } /** 讀取 --sort-order 並驗證為 0–1000000 的整數。 */ function readSortOrderFlag(flags: Record): number | undefined { const raw = flagString(flags, "sort-order"); if (raw === undefined) return undefined; const n = Number(raw); if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(n) || n < 0 || n > 1_000_000) { throw new UsageError(`--sort-order 須為 0–1000000 的整數:${raw}`); } return n; } export function registerNovelCommands(registry: Registry): void { registry.add({ path: ["novels", "get"], summary: "查單一作品(公開端點)", usage: "fox novels get ", run: async (ctx) => { const slug = requirePositional(ctx.args.positionals, 0, "slug", "fox novels get "); await call(ctx, `novels/${encodeURIComponent(slug)}`); }, }); registry.add({ path: ["novels", "list"], summary: "列出自己的作品(需授權)", usage: "fox novels list", run: async (ctx) => { await call(ctx, "novels", { auth: true }); }, }); registry.add({ path: ["novels", "create"], summary: "建立作品(需授權;slug 由書名自動生成,衝突時加流水號)", usage: "fox novels create --title <書名> [--slug ] [--synopsis <簡介>] [--tags <標籤1,標籤2>]", run: async (ctx) => { const usage = "fox novels create --title <書名> [--slug ] [--synopsis <簡介>] [--tags <標籤1,標籤2>]"; const title = await requireFlag(ctx.args.flags, "title", usage); const body: Record = { title }; const slug = flagString(ctx.args.flags, "slug"); if (slug !== undefined) body.slug = slug.trim(); const synopsis = flagString(ctx.args.flags, "synopsis"); if (synopsis !== undefined) body.synopsis = synopsis; const tags = flagString(ctx.args.flags, "tags"); if (tags !== undefined) body.tags = tags; await call(ctx, "novels", { method: "POST", body, auth: true }); }, }); registry.add({ path: ["novels", "update"], summary: "更新作品(需授權;只傳要改的欄位)", usage: "fox novels update [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]", run: async (ctx) => { const usage = "fox novels update [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]"; const slug = requirePositional(ctx.args.positionals, 0, "slug", usage); const body: Record = {}; const title = flagString(ctx.args.flags, "title"); if (title !== undefined) body.title = title.trim(); const newSlug = flagString(ctx.args.flags, "slug"); if (newSlug !== undefined) body.slug = newSlug.trim(); const synopsis = flagString(ctx.args.flags, "synopsis"); if (synopsis !== undefined) body.synopsis = synopsis; const tags = flagString(ctx.args.flags, "tags"); if (tags !== undefined) body.tags = tags; const status = readNovelStatusFlag(ctx.args.flags); if (status !== undefined) body.status = status; if (Object.keys(body).length === 0) { throw new UsageError(`沒有指定任何要更新的欄位;用法:${usage}`); } await call(ctx, `novels/${encodeURIComponent(slug)}`, { method: "PATCH", body, auth: true, }); }, }); } export function registerChapterCommands(registry: Registry): void { registry.add({ path: ["chapters", "list"], summary: "列章節(公開僅 published;帶作者 token 可見全部)", usage: "fox chapters list ", run: async (ctx) => { const slug = requirePositional(ctx.args.positionals, 0, "novelSlug", "fox chapters list "); // 公開端點:有 token 就帶上(作者本人可見 draft/hidden),沒有也照常呼叫。 const config = await loadConfig({ apiUrl: flagString(ctx.args.flags, "api-url"), token: flagString(ctx.args.flags, "token"), }); const data = await request( config, `novels/${encodeURIComponent(slug)}/chapters`, config.token ? { auth: true } : {}, ); ctx.print(data); }, }); registry.add({ path: ["chapters", "get"], summary: "查單一章節(draft 僅作者/admin)", usage: "fox chapters get ", run: async (ctx) => { const usage = "fox chapters get "; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage); const config = await loadConfig({ apiUrl: flagString(ctx.args.flags, "api-url"), token: flagString(ctx.args.flags, "token"), }); const data = await request( config, `novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`, config.token ? { auth: true } : {}, ); ctx.print(data); }, }); registry.add({ path: ["chapters", "create"], summary: "建立章節(需授權;slug 由標題生成,狀態預設 draft)", usage: "fox chapters create --title <標題> [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]", run: async (ctx) => { const usage = "fox chapters create --title <標題> [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]"; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); const title = await requireFlag(ctx.args.flags, "title", usage); const body: Record = { title }; const slug = flagString(ctx.args.flags, "slug"); if (slug !== undefined) body.slug = slug.trim(); const content = await readValue("content", flagString(ctx.args.flags, "content")); if (content !== undefined) body.content = content; const sortOrder = readSortOrderFlag(ctx.args.flags); if (sortOrder !== undefined) body.sortOrder = sortOrder; const status = readChapterStatusFlag(ctx.args.flags); if (status !== undefined) body.status = status; await call(ctx, `novels/${encodeURIComponent(novelSlug)}/chapters`, { method: "POST", body, auth: true, }); }, }); registry.add({ path: ["chapters", "update"], summary: "更新章節(需授權;只傳要改的欄位)", usage: "fox chapters update [--title <標題>] [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]", run: async (ctx) => { const usage = "fox chapters update [--title <標題>] [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]"; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage); const body: Record = {}; const title = flagString(ctx.args.flags, "title"); if (title !== undefined) body.title = title.trim(); const slug = flagString(ctx.args.flags, "slug"); if (slug !== undefined) body.slug = slug.trim(); const content = await readValue("content", flagString(ctx.args.flags, "content")); if (content !== undefined) body.content = content; const sortOrder = readSortOrderFlag(ctx.args.flags); if (sortOrder !== undefined) body.sortOrder = sortOrder; const status = readChapterStatusFlag(ctx.args.flags); if (status !== undefined) body.status = status; if (Object.keys(body).length === 0) { throw new UsageError(`沒有指定任何要更新的欄位;用法:${usage}`); } await call( ctx, `novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`, { method: "PATCH", body, auth: true }, ); }, }); registry.add({ path: ["chapters", "delete"], summary: "刪除章節(需授權;冪等)", usage: "fox chapters delete ", run: async (ctx) => { const usage = "fox chapters delete "; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage); await call( ctx, `novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`, { method: "DELETE", auth: true }, ); }, }); } export function registerGenerateCommands(registry: Registry): void { /** 共用:組生成請求 body 並 POST。 */ async function postGenerate( ctx: CommandContext, novelSlug: string, action: "draft" | "continue" | "rewrite", ): Promise { const usage = `fox generate ${action} [--prompt <指示|->] [--previous-context <前文摘要|->]`; const body: Record = {}; const prompt = await readValue("prompt", flagString(ctx.args.flags, "prompt")); if (prompt !== undefined && prompt.trim()) body.prompt = prompt.trim(); const previousContext = await readValue( "previous-context", flagString(ctx.args.flags, "previous-context"), ); if (previousContext !== undefined && previousContext.trim()) { body.previousContext = previousContext.trim(); } if (action === "draft" && body.prompt === undefined) { throw new UsageError(`缺少 --prompt <指示>;用法:${usage}`); } if (action === "continue" && body.previousContext === undefined) { throw new UsageError(`續寫需提供前文摘要;用法:${usage}`); } await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate${action === "draft" ? "" : `/${action}`}`, { method: "POST", body, auth: true, }); } registry.add({ path: ["generate", "draft"], summary: "草稿生成(需授權;token 需 generate scope)", usage: "fox generate draft --prompt <指示|-> [--previous-context <前文摘要|->]", run: async (ctx) => { const usage = "fox generate draft --prompt <指示|-> [--previous-context <前文摘要|->]"; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); await postGenerate(ctx, novelSlug, "draft"); }, }); registry.add({ path: ["generate", "continue"], summary: "續寫(需授權;--previous-context 必填)", usage: "fox generate continue --previous-context <前文摘要|-> [--prompt <指示|->]", run: async (ctx) => { const usage = "fox generate continue --previous-context <前文摘要|-> [--prompt <指示|->]"; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); await postGenerate(ctx, novelSlug, "continue"); }, }); registry.add({ path: ["generate", "rewrite"], summary: "改寫(需授權;原文放在 --prompt)", usage: "fox generate rewrite --prompt <原文|-> [--previous-context <前文摘要|->]", run: async (ctx) => { const usage = "fox generate rewrite --prompt <原文|-> [--previous-context <前文摘要|->]"; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); await postGenerate(ctx, novelSlug, "rewrite"); }, }); registry.add({ path: ["generate", "usages"], summary: "查詢生成用量記帳(需授權;作者/admin)", usage: "fox generate usages ", run: async (ctx) => { const usage = "fox generate usages "; const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate/usages`, { auth: true, }); }, }); } /** 共用型別:命令實作可引用的介面(保持單一來源)。 */ export type { Command };