Files
fox-cli/src/commands.ts
T
alex 77f30c1b6f feat(cli): 章節與寫作命令(fox chapters/fox generate,issue #43)
- fox chapters list/get/create/update/delete:對接 #40 章節 CRUD 端點;
  公開命令(list/get)在持有 token 時自動帶上(作者本人可見 draft/hidden)。
- fox generate draft/continue/rewrite/usages:對接 #42 生成 API;
  需 generate scope 的 access token。
- 補齊 M3:fox novels list/create/update(Bearer 授權)+
  tokens create --scopes。
- --content/--prompt/--previous-context 支援 '-' 從 stdin 讀多行內容。
- 輸出維持單一 JSON(stdout)、exit code 0/1/2 規範不變。
- cli/README.md:命令表、授權說明與 M3 里程碑更新。

本機後端(Postgres+NestJS,echo 供應商)端到端實測 44 項全過。
2026-09-06 23:32:48 +08:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
/**
* 讀取內容類選項(--content/--prompt/--previous-context)的值。
* 值為 "-" 時改從 stdin 讀入全部內容(適合多行文字經管線傳入);
* stdin 是終端機時直接報用法錯誤,避免掛起等待輸入。
*/
async function readValue(
flagName: string,
value: string | undefined,
): Promise<string | undefined> {
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<string, string | boolean>,
flagName: string,
usage: string,
): Promise<string> {
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 <ISO8601>] [--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 <ISO8601>] [--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<string, unknown> = { 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 <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 });
},
});
}
const CHAPTER_USAGE =
"fox chapters <list|get|create|update|delete> <novelSlug> …(見 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, string | boolean>): 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, string | boolean>): 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<string, string | boolean>): 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 <slug>",
run: async (ctx) => {
const slug = requirePositional(ctx.args.positionals, 0, "slug", "fox novels get <slug>");
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 <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]",
run: async (ctx) => {
const usage =
"fox novels create --title <書名> [--slug <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]";
const title = await requireFlag(ctx.args.flags, "title", usage);
const body: Record<string, unknown> = { 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 <slug> [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox novels update <slug> [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]";
const slug = requirePositional(ctx.args.positionals, 0, "slug", usage);
const body: Record<string, unknown> = {};
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 <novelSlug>",
run: async (ctx) => {
const slug = requirePositional(ctx.args.positionals, 0, "novelSlug", "fox chapters list <novelSlug>");
// 公開端點:有 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 <novelSlug> <chapterSlug>",
run: async (ctx) => {
const usage = "fox chapters get <novelSlug> <chapterSlug>";
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 <novelSlug> --title <標題> [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox chapters create <novelSlug> --title <標題> [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const title = await requireFlag(ctx.args.flags, "title", usage);
const body: Record<string, unknown> = { 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 <novelSlug> <chapterSlug> [--title <標題>] [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox chapters update <novelSlug> <chapterSlug> [--title <標題>] [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage);
const body: Record<string, unknown> = {};
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 <novelSlug> <chapterSlug>",
run: async (ctx) => {
const usage = "fox chapters delete <novelSlug> <chapterSlug>";
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<void> {
const usage = `fox generate ${action} <novelSlug> [--prompt <指示|->] [--previous-context <前文摘要|->]`;
const body: Record<string, unknown> = {};
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 <novelSlug> --prompt <指示|-> [--previous-context <前文摘要|->]",
run: async (ctx) => {
const usage =
"fox generate draft <novelSlug> --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 <novelSlug> --previous-context <前文摘要|-> [--prompt <指示|->]",
run: async (ctx) => {
const usage =
"fox generate continue <novelSlug> --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 <novelSlug> --prompt <原文|-> [--previous-context <前文摘要|->]",
run: async (ctx) => {
const usage =
"fox generate rewrite <novelSlug> --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 <novelSlug>",
run: async (ctx) => {
const usage = "fox generate usages <novelSlug>";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate/usages`, {
auth: true,
});
},
});
}
/** 共用型別:命令實作可引用的介面(保持單一來源)。 */
export type { Command };