feat(cli): whole-book backup and restore

新增整本书的备份与恢复,解决 issue #339 第三项诉求(agent 改坏后
无处回档):

- 新增 book-backup.ts:备份是把 books/<book-id>/ 整目录复制到
  .inkos/backups/<book-id>/<UTC时间戳>/。备份存放在书目录之外,
  所以备份内容天然不含备份目录自身。时间戳时钟可注入(测试不依赖
  真实时间),同一秒内的多个备份自动加 -2/-3 后缀。恢复前先把当前
  书目录自动备份为 <时间戳>-pre-restore(防手滑),再整目录还原;
  backup-id 是用户输入并拼进路径,校验为单个目录名,拒绝路径分隔符。
- CLI 新增 inkos book backup <book-id>(--list 列出既有备份)和
  inkos book restore <book-id> <backup-id>,均支持 --json,
  人类可读输出按环境语言双语显示(备份要能用于 book.json 已损坏
  的书,所以不读书籍配置来决定语言)。
This commit is contained in:
Ma
2026-07-15 16:19:44 +08:00
parent e25848fd71
commit 1420040f16
4 changed files with 409 additions and 0 deletions
@@ -0,0 +1,165 @@
import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBookBackup, listBookBackups, restoreBookBackup } from "../book-backup.js";
const logMock = vi.fn();
const logErrorMock = vi.fn();
let projectRoot = "";
vi.mock("../utils.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils.js")>()),
findProjectRoot: () => projectRoot,
log: (message: string) => logMock(message),
logError: (message: string) => logErrorMock(message),
}));
async function exists(path: string): Promise<boolean> {
return access(path).then(() => true).catch(() => false);
}
async function setupBook(bookId: string): Promise<string> {
projectRoot = await mkdtemp(join(tmpdir(), "inkos-book-backup-"));
const bookDir = join(projectRoot, "books", bookId);
await mkdir(join(bookDir, "chapters"), { recursive: true });
await mkdir(join(bookDir, "story"), { recursive: true });
await writeFile(join(bookDir, "book.json"), JSON.stringify({ id: bookId, title: bookId, language: "zh" }), "utf-8");
await writeFile(join(bookDir, "chapters", "0001_起风.md"), "第一章原文。", "utf-8");
await writeFile(join(bookDir, "story", "current_state.md"), "原始状态", "utf-8");
return bookDir;
}
const fixedClock = (iso: string) => () => new Date(iso);
describe("book backup module", () => {
it("snapshots the whole book directory into .inkos/backups/<bookId>/<stamp>/", async () => {
const bookDir = await setupBook("backbook");
const result = await createBookBackup(projectRoot, "backbook", { now: fixedClock("2026-07-15T08:12:33Z") });
expect(result.backupId).toBe("20260715-081233");
const backupDir = join(projectRoot, ".inkos", "backups", "backbook", "20260715-081233");
await expect(readFile(join(backupDir, "chapters", "0001_起风.md"), "utf-8")).resolves.toBe("第一章原文。");
await expect(readFile(join(backupDir, "story", "current_state.md"), "utf-8")).resolves.toBe("原始状态");
// The original book stays in place.
await expect(exists(join(bookDir, "book.json"))).resolves.toBe(true);
});
it("produces distinct ids for two backups taken at the same clock instant", async () => {
await setupBook("twinbook");
const now = fixedClock("2026-07-15T08:12:33Z");
const first = await createBookBackup(projectRoot, "twinbook", { now });
const second = await createBookBackup(projectRoot, "twinbook", { now });
expect(first.backupId).toBe("20260715-081233");
expect(second.backupId).toBe("20260715-081233-2");
});
it("lists backups newest first", async () => {
await setupBook("listbook");
await createBookBackup(projectRoot, "listbook", { now: fixedClock("2026-07-14T10:00:00Z") });
await createBookBackup(projectRoot, "listbook", { now: fixedClock("2026-07-15T10:00:00Z") });
const backups = await listBookBackups(projectRoot, "listbook");
expect(backups.map((b) => b.id)).toEqual(["20260715-100000", "20260714-100000"]);
expect(backups[0]?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("returns an empty list for a book without backups", async () => {
await setupBook("nobackups");
await expect(listBookBackups(projectRoot, "nobackups")).resolves.toEqual([]);
});
it("restores a backup and auto-backs-up the current state first", async () => {
const bookDir = await setupBook("restorebook");
const backup = await createBookBackup(projectRoot, "restorebook", { now: fixedClock("2026-07-15T08:00:00Z") });
await writeFile(join(bookDir, "chapters", "0001_起风.md"), "改坏了的第一章。", "utf-8");
await writeFile(join(bookDir, "chapters", "0002_多余.md"), "多写的一章。", "utf-8");
const result = await restoreBookBackup(projectRoot, "restorebook", backup.backupId, {
now: fixedClock("2026-07-15T09:00:00Z"),
});
expect(result.restoredFrom).toBe("20260715-080000");
expect(result.preRestoreBackupId).toBe("20260715-090000-pre-restore");
// Content is back to the backup point, including removal of extra files.
await expect(readFile(join(bookDir, "chapters", "0001_起风.md"), "utf-8")).resolves.toBe("第一章原文。");
await expect(exists(join(bookDir, "chapters", "0002_多余.md"))).resolves.toBe(false);
// The pre-restore auto-backup preserves the botched state.
const preRestoreDir = join(projectRoot, ".inkos", "backups", "restorebook", "20260715-090000-pre-restore");
await expect(readFile(join(preRestoreDir, "chapters", "0001_起风.md"), "utf-8")).resolves.toBe("改坏了的第一章。");
await expect(readFile(join(preRestoreDir, "chapters", "0002_多余.md"), "utf-8")).resolves.toBe("多写的一章。");
});
it("rejects backing up a book that does not exist", async () => {
await setupBook("realbook");
await expect(createBookBackup(projectRoot, "ghostbook")).rejects.toThrow(/not found/i);
});
it("rejects restoring an unknown backup id", async () => {
await setupBook("orphanbook");
await expect(restoreBookBackup(projectRoot, "orphanbook", "20990101-000000"))
.rejects.toThrow(/not found/i);
});
it("rejects backup ids containing path separators", async () => {
await setupBook("evilbook");
await expect(restoreBookBackup(projectRoot, "evilbook", "../../books/evilbook"))
.rejects.toThrow(/backup id/i);
});
});
describe("inkos book backup / restore commands", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a backup, lists it, and restores it via the CLI", async () => {
const bookDir = await setupBook("cliflow");
const { bookCommand } = await import("../commands/book.js");
await bookCommand.parseAsync(["node", "book", "backup", "cliflow", "--json"], { from: "node" });
expect(logErrorMock).not.toHaveBeenCalled();
const created = JSON.parse(logMock.mock.calls.at(-1)?.[0] as string) as { backupId: string };
expect(created.backupId).toMatch(/^\d{8}-\d{6}/);
await bookCommand.parseAsync(["node", "book", "backup", "cliflow", "--list", "--json"], { from: "node" });
const listed = JSON.parse(logMock.mock.calls.at(-1)?.[0] as string) as {
backups: ReadonlyArray<{ id: string }>;
};
expect(listed.backups.map((b) => b.id)).toContain(created.backupId);
await writeFile(join(bookDir, "chapters", "0001_起风.md"), "改坏了。", "utf-8");
await bookCommand.parseAsync(["node", "book", "restore", "cliflow", created.backupId, "--json"], { from: "node" });
expect(logErrorMock).not.toHaveBeenCalled();
const restored = JSON.parse(logMock.mock.calls.at(-1)?.[0] as string) as {
restoredFrom: string;
preRestoreBackupId: string | null;
};
expect(restored.restoredFrom).toBe(created.backupId);
expect(restored.preRestoreBackupId).not.toBeNull();
await expect(readFile(join(bookDir, "chapters", "0001_起风.md"), "utf-8")).resolves.toBe("第一章原文。");
});
it("fails with exit code 1 when restoring a backup that does not exist", async () => {
await setupBook("clibroken");
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
try {
const { bookCommand } = await import("../commands/book.js");
await bookCommand.parseAsync(["node", "book", "restore", "clibroken", "20990101-000000"], { from: "node" });
expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining("not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
}
});
});
+132
View File
@@ -0,0 +1,132 @@
import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
export interface BookBackupInfo {
readonly id: string;
readonly createdAt: string;
}
export interface CreateBookBackupOptions {
/** Injectable clock so tests do not depend on real time. */
readonly now?: () => Date;
/** Appended to the backup id, e.g. "pre-restore". */
readonly suffix?: string;
}
export interface CreateBookBackupResult {
readonly bookId: string;
readonly backupId: string;
readonly path: string;
}
export interface RestoreBookBackupOptions {
readonly now?: () => Date;
}
export interface RestoreBookBackupResult {
readonly bookId: string;
readonly restoredFrom: string;
/** Auto-backup of the pre-restore state; null when the book directory did not exist. */
readonly preRestoreBackupId: string | null;
}
/**
* Whole-book backups live OUTSIDE books/ (at .inkos/backups/<bookId>/<backupId>/),
* so a backup never recursively contains other backups.
*/
export function bookBackupsDir(root: string, bookId: string): string {
return join(root, ".inkos", "backups", bookId);
}
export async function createBookBackup(
root: string,
bookId: string,
options: CreateBookBackupOptions = {},
): Promise<CreateBookBackupResult> {
const bookDir = join(root, "books", bookId);
const bookInfo = await stat(bookDir).catch(() => null);
if (!bookInfo?.isDirectory()) {
throw new Error(`Book "${bookId}" not found at books/${bookId}/.`);
}
const backupsDir = bookBackupsDir(root, bookId);
await mkdir(backupsDir, { recursive: true });
const clock = options.now ?? (() => new Date());
const base = options.suffix ? `${formatStamp(clock())}-${options.suffix}` : formatStamp(clock());
let backupId = base;
for (let attempt = 2; await pathExists(join(backupsDir, backupId)); attempt += 1) {
backupId = `${base}-${attempt}`;
}
const backupPath = join(backupsDir, backupId);
await cp(bookDir, backupPath, { recursive: true });
return { bookId, backupId, path: backupPath };
}
export async function listBookBackups(
root: string,
bookId: string,
): Promise<ReadonlyArray<BookBackupInfo>> {
const backupsDir = bookBackupsDir(root, bookId);
const entries = await readdir(backupsDir, { withFileTypes: true }).catch((error) => {
if ((error as { code?: unknown }).code === "ENOENT") {
return [];
}
throw error;
});
const backups = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const info = await stat(join(backupsDir, entry.name));
return { id: entry.name, createdAt: info.mtime.toISOString() };
}),
);
// Backup ids start with a UTC timestamp, so a descending id sort is newest-first.
return backups.sort((a, b) => b.id.localeCompare(a.id));
}
export async function restoreBookBackup(
root: string,
bookId: string,
backupId: string,
options: RestoreBookBackupOptions = {},
): Promise<RestoreBookBackupResult> {
// backupId comes from CLI input and is joined into a path — keep it a single
// safe path component.
if (!/^[A-Za-z0-9._-]+$/.test(backupId) || backupId === "." || backupId === "..") {
throw new Error(`Invalid backup id "${backupId}": a backup id must be a single directory name.`);
}
const backupPath = join(bookBackupsDir(root, bookId), backupId);
const backupInfo = await stat(backupPath).catch(() => null);
if (!backupInfo?.isDirectory()) {
throw new Error(
`Backup "${backupId}" not found for book "${bookId}". `
+ `List available backups with: inkos book backup ${bookId} --list`,
);
}
const bookDir = join(root, "books", bookId);
const bookExists = await stat(bookDir).then((info) => info.isDirectory()).catch(() => false);
let preRestoreBackupId: string | null = null;
if (bookExists) {
const preRestore = await createBookBackup(root, bookId, { now: options.now, suffix: "pre-restore" });
preRestoreBackupId = preRestore.backupId;
}
await rm(bookDir, { recursive: true, force: true });
await cp(backupPath, bookDir, { recursive: true });
return { bookId, restoredFrom: backupId, preRestoreBackupId };
}
function formatStamp(date: Date): string {
return date.toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
}
async function pathExists(path: string): Promise<boolean> {
return access(path).then(() => true).catch(() => false);
}
+79
View File
@@ -4,13 +4,17 @@ import { createInterface } from "node:readline";
import { join, resolve } from "node:path";
import { deriveBookIdFromTitle, normalizePlatformOrOther, PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
import {
formatBookBackupCreated,
formatBookBackupListEmpty,
formatBookCreateCreated,
formatBookCreateCreating,
formatBookCreateFoundationReady,
formatBookCreateLocation,
formatBookCreateNextStep,
formatBookRestoreDone,
resolveCliLanguage,
} from "../localization.js";
import { createBookBackup, listBookBackups, restoreBookBackup } from "../book-backup.js";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
export const bookCommand = new Command("book")
@@ -258,3 +262,78 @@ bookCommand
process.exit(1);
}
});
bookCommand
.command("backup")
.description("Snapshot the whole book directory into .inkos/backups/<book-id>/ (or list backups with --list)")
.argument("<book-id>", "Book ID")
.option("--list", "List existing backups instead of creating one")
.option("--json", "Output JSON")
.action(async (bookId: string, opts) => {
try {
const root = findProjectRoot();
// Backups must also work on broken books (e.g. corrupted book.json),
// so the output language follows the environment, not the book config.
const language = resolveCliLanguage();
if (opts.list) {
const backups = await listBookBackups(root, bookId);
if (opts.json) {
log(JSON.stringify({ bookId, backups }, null, 2));
} else if (backups.length === 0) {
log(formatBookBackupListEmpty(language, bookId));
} else {
for (const backup of backups) {
log(` ${backup.id} ${backup.createdAt}`);
}
}
return;
}
const result = await createBookBackup(root, bookId);
if (opts.json) {
log(JSON.stringify({ bookId, backupId: result.backupId }, null, 2));
} else {
log(formatBookBackupCreated(language, bookId, result.backupId));
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Failed to back up book: ${e}`);
}
process.exit(1);
}
});
bookCommand
.command("restore")
.description("Restore a whole-book backup (the current book state is automatically backed up first)")
.argument("<book-id>", "Book ID")
.argument("<backup-id>", "Backup ID, see `inkos book backup <book-id> --list`")
.option("--json", "Output JSON")
.action(async (bookId: string, backupId: string, opts) => {
try {
const root = findProjectRoot();
const language = resolveCliLanguage();
const result = await restoreBookBackup(root, bookId, backupId);
if (opts.json) {
log(JSON.stringify(result, null, 2));
} else {
log(formatBookRestoreDone(language, {
bookId,
backupId: result.restoredFrom,
preRestoreBackupId: result.preRestoreBackupId,
}));
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Failed to restore book: ${e}`);
}
process.exit(1);
}
});
+33
View File
@@ -509,3 +509,36 @@ export function formatChapterDeleteDone(
en: `Deleted chapter ${params.number} ${params.title}: chapter file kept at ${trashNote}; index and story state rolled back to chapter ${params.rolledBackTo}.`,
});
}
export function formatBookBackupCreated(language: CliLanguage, bookId: string, backupId: string): string {
return localize(language, {
zh: `已备份 ${bookId} → .inkos/backups/${bookId}/${backupId}/`,
en: `Backed up ${bookId} → .inkos/backups/${bookId}/${backupId}/`,
});
}
export function formatBookBackupListEmpty(language: CliLanguage, bookId: string): string {
return localize(language, {
zh: `${bookId} 还没有备份。用 inkos book backup ${bookId} 创建一份。`,
en: `No backups for ${bookId} yet. Create one with: inkos book backup ${bookId}`,
});
}
export function formatBookRestoreDone(
language: CliLanguage,
params: { bookId: string; backupId: string; preRestoreBackupId: string | null },
): string {
const preNote = params.preRestoreBackupId
? localize(language, {
zh: `恢复前的状态已自动备份为 ${params.preRestoreBackupId}`,
en: `The pre-restore state was automatically backed up as ${params.preRestoreBackupId}.`,
})
: localize(language, {
zh: "书目录当时不存在,未创建恢复前备份。",
en: "The book directory did not exist, so no pre-restore backup was created.",
});
return localize(language, {
zh: `已把 ${params.bookId} 恢复到备份 ${params.backupId}${preNote}`,
en: `Restored ${params.bookId} to backup ${params.backupId}. ${preNote}`,
});
}