diff --git a/packages/cli/src/__tests__/book-backup.test.ts b/packages/cli/src/__tests__/book-backup.test.ts new file mode 100644 index 00000000..9e2c8cc1 --- /dev/null +++ b/packages/cli/src/__tests__/book-backup.test.ts @@ -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()), + findProjectRoot: () => projectRoot, + log: (message: string) => logMock(message), + logError: (message: string) => logErrorMock(message), +})); + +async function exists(path: string): Promise { + return access(path).then(() => true).catch(() => false); +} + +async function setupBook(bookId: string): Promise { + 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///", 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(); + } + }); +}); diff --git a/packages/cli/src/book-backup.ts b/packages/cli/src/book-backup.ts new file mode 100644 index 00000000..ca87fcb2 --- /dev/null +++ b/packages/cli/src/book-backup.ts @@ -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///), + * 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 { + 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> { + 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 { + // 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 { + return access(path).then(() => true).catch(() => false); +} diff --git a/packages/cli/src/commands/book.ts b/packages/cli/src/commands/book.ts index 10c7a9e0..0a3f2e3e 100644 --- a/packages/cli/src/commands/book.ts +++ b/packages/cli/src/commands/book.ts @@ -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// (or list backups with --list)") + .argument("", "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") + .argument("", "Backup ID, see `inkos book backup --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); + } + }); diff --git a/packages/cli/src/localization.ts b/packages/cli/src/localization.ts index 78803bce..85be05ae 100644 --- a/packages/cli/src/localization.ts +++ b/packages/cli/src/localization.ts @@ -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}`, + }); +}