mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user