- 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 項全過。
465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
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 };
|