| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { basename, dirname, join, resolve } from "node:path";
- import { fileURLToPath } from "node:url";
- import { execFileSync } from "node:child_process";
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
- import { StateManager } from "@actalk/inkos-core";
- const testDir = dirname(fileURLToPath(import.meta.url));
- const cliDir = resolve(testDir, "..", "..");
- const cliEntry = resolve(cliDir, "dist", "index.js");
- let projectDir: string;
- function run(args: string[], options?: { env?: Record<string, string> }): string {
- return execFileSync("node", [cliEntry, ...args], {
- cwd: projectDir,
- encoding: "utf-8",
- env: {
- ...process.env,
- // Prevent global config from leaking into tests
- HOME: projectDir,
- ...options?.env,
- },
- timeout: 10_000,
- });
- }
- function runStderr(args: string[], options?: { env?: Record<string, string> }): { stdout: string; stderr: string; exitCode: number } {
- try {
- const stdout = execFileSync("node", [cliEntry, ...args], {
- cwd: projectDir,
- encoding: "utf-8",
- env: { ...process.env, HOME: projectDir, ...options?.env },
- timeout: 10_000,
- });
- return { stdout, stderr: "", exitCode: 0 };
- } catch (e: unknown) {
- const err = e as { stdout: string; stderr: string; status: number };
- return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
- }
- }
- const failingLlmEnv = {
- INKOS_LLM_PROVIDER: "openai",
- INKOS_LLM_BASE_URL: "http://127.0.0.1:9/v1",
- INKOS_LLM_MODEL: "test-model",
- INKOS_LLM_API_KEY: "test-key",
- };
- describe("CLI integration", () => {
- beforeAll(async () => {
- projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
- });
- afterAll(async () => {
- await rm(projectDir, { recursive: true, force: true });
- });
- describe("inkos --version", () => {
- it("prints version number", () => {
- const output = run(["--version"]);
- expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
- });
- });
- describe("inkos --help", () => {
- it("prints help with command list", () => {
- const output = run(["--help"]);
- expect(output).toContain("inkos");
- expect(output).toContain("init");
- expect(output).toContain("book");
- expect(output).toContain("write");
- });
- });
- describe("inkos init", () => {
- it("initializes project in current directory", () => {
- const output = run(["init"]);
- expect(output).toContain("Project initialized");
- });
- it("creates inkos.json with correct structure", async () => {
- const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.llm).toBeDefined();
- expect(config.llm.provider).toBeDefined();
- expect(config.llm.model).toBeDefined();
- expect(config.daemon).toBeDefined();
- expect(config.notify).toEqual([]);
- });
- it("creates .env file", async () => {
- const envContent = await readFile(join(projectDir, ".env"), "utf-8");
- expect(envContent).toContain("INKOS_LLM_API_KEY");
- });
- it("creates .gitignore", async () => {
- const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8");
- expect(gitignore).toContain(".env");
- });
- it("creates Node version hints for sqlite-backed memory features", async () => {
- await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toContain("22");
- await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toContain("22");
- });
- it("creates books/ and radar/ directories", async () => {
- const booksStat = await stat(join(projectDir, "books"));
- expect(booksStat.isDirectory()).toBe(true);
- const radarStat = await stat(join(projectDir, "radar"));
- expect(radarStat.isDirectory()).toBe(true);
- });
- });
- describe("inkos init <name>", () => {
- it("creates project in subdirectory", () => {
- const output = run(["init", "subproject"]);
- expect(output).toContain("Project initialized");
- });
- it("creates inkos.json in subdirectory", async () => {
- const raw = await readFile(join(projectDir, "subproject", "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.name).toBe("subproject");
- });
- it("supports absolute project paths instead of nesting them under cwd", async () => {
- const absoluteDir = await mkdtemp(join(tmpdir(), "inkos-cli-abs-init-"));
- try {
- const output = run(["init", absoluteDir]);
- expect(output).toContain(`Project initialized at ${absoluteDir}`);
- const raw = await readFile(join(absoluteDir, "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.name).toBe(basename(absoluteDir));
- } finally {
- await rm(absoluteDir, { recursive: true, force: true });
- }
- });
- it("prints English next steps when initialized with --lang en", async () => {
- const englishDir = await mkdtemp(join(tmpdir(), "inkos-cli-en-init-"));
- try {
- const output = run(["init", englishDir, "--lang", "en"]);
- expect(output).toContain("Project initialized");
- expect(output).toContain("inkos book create --title 'My Novel'");
- expect(output).not.toContain("我的小说");
- } finally {
- await rm(englishDir, { recursive: true, force: true });
- }
- });
- });
- describe("inkos config set", () => {
- it("sets a known config value", () => {
- const output = run(["config", "set", "llm.provider", "anthropic"]);
- expect(output).toContain("Set llm.provider = anthropic");
- });
- it("sets a nested config value", async () => {
- run(["config", "set", "llm.model", "gpt-5"]);
- const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.llm.model).toBe("gpt-5");
- });
- it("rejects unknown config keys", () => {
- expect(() => {
- run(["config", "set", "custom.nested.key", "value"]);
- }).toThrow();
- });
- it("sets input governance mode", async () => {
- const output = run(["config", "set", "inputGovernanceMode", "v2"]);
- expect(output).toContain("Set inputGovernanceMode = v2");
- const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.inputGovernanceMode).toBe("v2");
- });
- });
- describe("inkos config show", () => {
- it("shows current config as JSON", () => {
- const output = run(["config", "show"]);
- const config = JSON.parse(output);
- expect(config.llm.model).toBe("gpt-5");
- });
- });
- describe("inkos interact", () => {
- it("returns structured JSON for shared interaction mode switches", async () => {
- const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
- if (!initialized) run(["init"]);
- await writeFile(
- join(projectDir, ".env"),
- Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
- "utf-8",
- );
- const output = run(["interact", "--json", "--message", "切换到全自动"]);
- const data = JSON.parse(output);
- expect(data.request.intent).toBe("switch_mode");
- expect(data.request.mode).toBe("auto");
- expect(data.session.automationMode).toBe("auto");
- });
- it("binds the requested book when interact is called with --book", async () => {
- const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
- if (!initialized) run(["init"]);
- await writeFile(
- join(projectDir, ".env"),
- Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
- "utf-8",
- );
- const state = new StateManager(projectDir);
- await state.saveBookConfig("harbor", {
- id: "harbor",
- title: "Harbor",
- platform: "tomato",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 3000,
- createdAt: "2026-04-07T00:00:00.000Z",
- updatedAt: "2026-04-07T00:00:00.000Z",
- });
- const output = run(["interact", "--json", "--book", "harbor", "--message", "explain status"]);
- const data = JSON.parse(output);
- expect(data.session.activeBookId).toBe("harbor");
- });
- });
- describe("inkos config set-model", () => {
- it("rejects raw API keys passed to --api-key-env", async () => {
- const { exitCode, stderr } = runStderr([
- "config",
- "set-model",
- "writer",
- "gpt-4-turbo",
- "--provider",
- "custom",
- "--base-url",
- "https://poloai.top/v1",
- "--api-key-env",
- "sk-test-direct-key",
- ]);
- expect(exitCode).not.toBe(0);
- expect(stderr).toContain("--api-key-env expects an environment variable name");
- const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
- const config = JSON.parse(raw);
- expect(config.modelOverrides).toBeUndefined();
- });
- });
- describe("inkos book list", () => {
- it("shows no books in empty project", () => {
- const output = run(["book", "list"]);
- expect(output).toContain("No books found");
- });
- it("returns empty array in JSON mode", () => {
- const output = run(["book", "list", "--json"]);
- const data = JSON.parse(output);
- expect(data.books).toEqual([]);
- });
- });
- describe("inkos book create", () => {
- it("removes stale incomplete book directories before retrying create", async () => {
- try {
- await stat(join(projectDir, "inkos.json"));
- } catch {
- run(["init"]);
- }
- const bookId = "stale-book";
- const staleDir = join(projectDir, "books", bookId);
- await mkdir(join(staleDir, "story"), { recursive: true });
- await writeFile(join(staleDir, "book.json"), JSON.stringify({
- id: bookId,
- title: "Stale Book",
- }, null, 2));
- await writeFile(join(staleDir, "story", "current_state.md"), "# stale\n", "utf-8");
- const { exitCode, stderr } = runStderr([
- "book",
- "create",
- "--title",
- "stale book",
- ], {
- env: failingLlmEnv,
- });
- expect(exitCode).not.toBe(0);
- expect(stderr).toContain("Failed to create book");
- await expect(stat(staleDir)).rejects.toThrow();
- });
- });
- describe("inkos status", () => {
- it("shows project status with zero books", () => {
- const output = run(["status"]);
- expect(output).toContain("Books: 0");
- });
- it("returns JSON with --json flag", () => {
- const output = run(["status", "--json"]);
- const data = JSON.parse(output);
- expect(data.project).toBeDefined();
- expect(data.books).toEqual([]);
- });
- it("errors for nonexistent book", () => {
- const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
- expect(exitCode).not.toBe(0);
- });
- it("shows English chapter counts in words for chapter rows", async () => {
- const bookDir = join(projectDir, "books", "english-status");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "english-status",
- title: "English Status Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(
- join(bookDir, "chapters", "index.json"),
- JSON.stringify([
- {
- number: 1,
- title: "A Quiet Sky",
- status: "ready-for-review",
- wordCount: 7,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- },
- ], null, 2),
- "utf-8",
- );
- const output = run(["status", "english-status", "--chapters"]);
- expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
- expect(output).not.toContain("7字");
- });
- it("shows degraded chapter counts and issues explicitly", async () => {
- const bookDir = join(projectDir, "books", "degraded-status");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "degraded-status",
- title: "Degraded Status Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(
- join(bookDir, "chapters", "index.json"),
- JSON.stringify([
- {
- number: 1,
- title: "Broken State",
- status: "state-degraded",
- wordCount: 1800,
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- auditIssues: ["[warning] state validation still failed after retry"],
- lengthWarnings: [],
- },
- ], null, 2),
- "utf-8",
- );
- const output = run(["status", "degraded-status", "--chapters"]);
- expect(output).toContain("Degraded: 1");
- expect(output).toContain('Ch.1 "Broken State" | 1800字 | state-degraded');
- expect(output).toContain("[warning] state validation still failed after retry");
- const json = JSON.parse(run(["status", "degraded-status", "--json"]));
- expect(json.books[0]?.degraded).toBe(1);
- });
- it("shows a migration hint for legacy pre-v0.6 books", async () => {
- const bookDir = join(projectDir, "books", "legacy-status-hint");
- const storyDir = join(bookDir, "story");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await mkdir(storyDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "legacy-status-hint",
- title: "Legacy Status Hint",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
- await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
- const output = run(["status", "legacy-status-hint"]);
- expect(output).toContain("legacy format");
- });
- it("reports persisted chapter file count instead of runtime progress when state runs ahead", async () => {
- const bookId = "ahead-status";
- const bookDir = join(projectDir, "books", bookId);
- const chaptersDir = join(bookDir, "chapters");
- const stateDir = join(bookDir, "story", "state");
- await mkdir(chaptersDir, { recursive: true });
- await mkdir(stateDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: bookId,
- title: "Ahead Status Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(chaptersDir, "0001_First.md"), "# 第1章 First\n\nOnly persisted chapter.", "utf-8");
- await writeFile(
- join(chaptersDir, "index.json"),
- JSON.stringify([
- {
- number: 1,
- title: "First",
- status: "ready-for-review",
- wordCount: 42,
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- },
- ], null, 2),
- "utf-8",
- );
- await Promise.all([
- writeFile(
- join(stateDir, "manifest.json"),
- JSON.stringify({
- schemaVersion: 2,
- language: "zh",
- lastAppliedChapter: 4,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "current_state.json"),
- JSON.stringify({
- chapter: 4,
- facts: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(join(stateDir, "hooks.json"), JSON.stringify({ hooks: [] }, null, 2), "utf-8"),
- writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({ rows: [] }, null, 2), "utf-8"),
- ]);
- const output = run(["status", bookId]);
- expect(output).toContain("Chapters: 1 / 10");
- expect(output).not.toContain("Chapters: 4 / 10");
- const json = JSON.parse(run(["status", bookId, "--json"]));
- expect(json.books[0]?.chapters).toBe(1);
- });
- });
- describe("inkos doctor", () => {
- it("checks environment health", () => {
- const { stdout } = runStderr(["doctor"]);
- expect(stdout).toContain("InkOS Doctor");
- expect(stdout).toContain("Node.js >= 20");
- expect(stdout).toContain("SQLite memory index");
- expect(stdout).toContain("inkos.json");
- });
- it("repairs missing node runtime pin files for old projects", async () => {
- await stat(join(projectDir, "inkos.json")).catch(() => {
- run(["init"]);
- });
- await rm(join(projectDir, ".nvmrc"), { force: true });
- await rm(join(projectDir, ".node-version"), { force: true });
- const before = runStderr(["doctor"]);
- expect(before.stdout).toContain("Node runtime pin files");
- expect(before.stdout).toContain(".nvmrc");
- expect(before.stdout).toContain(".node-version");
- const repaired = runStderr(["doctor", "--repair-node-runtime"]);
- expect(repaired.stdout).toContain("Node runtime pin files repaired");
- expect(repaired.stdout).toContain(".nvmrc");
- expect(repaired.stdout).toContain(".node-version");
- await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
- await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
- });
- it("treats localhost OpenAI-compatible endpoints as API-key optional", async () => {
- await stat(join(projectDir, "inkos.json")).catch(() => {
- run(["init"]);
- });
- const configPath = join(projectDir, "inkos.json");
- const envPath = join(projectDir, ".env");
- const originalConfig = await readFile(configPath, "utf-8");
- const originalEnv = await readFile(envPath, "utf-8");
- try {
- const config = JSON.parse(originalConfig);
- config.llm.provider = "openai";
- config.llm.baseUrl = "http://127.0.0.1:11434/v1";
- config.llm.model = "gpt-oss:20b";
- await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
- await writeFile(envPath, [
- "INKOS_LLM_PROVIDER=openai",
- "INKOS_LLM_BASE_URL=http://127.0.0.1:11434/v1",
- "INKOS_LLM_MODEL=gpt-oss:20b",
- "",
- ].join("\n"), "utf-8");
- const { stdout } = runStderr(["doctor"], {
- env: { INKOS_LLM_API_KEY: "" },
- });
- expect(stdout).toContain("LLM API Key");
- expect(stdout).toContain("Optional for local/self-hosted endpoint");
- expect(stdout).toContain("LLM Config");
- expect(stdout).not.toContain("No LLM config available");
- } finally {
- await writeFile(configPath, originalConfig, "utf-8");
- await writeFile(envPath, originalEnv, "utf-8");
- }
- });
- it("reports legacy books in the version migration check", async () => {
- const bookDir = join(projectDir, "books", "legacy-doctor-hint");
- const storyDir = join(bookDir, "story");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await mkdir(storyDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "legacy-doctor-hint",
- title: "Legacy Doctor Hint",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
- await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
- const { stdout } = runStderr(["doctor"]);
- expect(stdout).toContain("Version Migration");
- expect(stdout).toContain("legacy format");
- });
- });
- describe("inkos write", () => {
- it("warns before writing when the target book still uses legacy format", async () => {
- const bookDir = join(projectDir, "books", "legacy-write-hint");
- const storyDir = join(bookDir, "story");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await mkdir(storyDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "legacy-write-hint",
- title: "Legacy Write Hint",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
- await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
- const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], {
- env: failingLlmEnv,
- });
- expect(`${stdout}\n${stderr}`).toContain("legacy format");
- });
- it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
- const state = new StateManager(projectDir);
- const bookId = "rewrite-cli";
- const bookDir = join(projectDir, "books", bookId);
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- const stateDir = join(storyDir, "state");
- await mkdir(chaptersDir, { recursive: true });
- await mkdir(stateDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: bookId,
- title: "Rewrite CLI",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
- await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
- await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
- await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8");
- await writeFile(
- join(chaptersDir, "index.json"),
- JSON.stringify([
- { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
- { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
- { number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
- ], null, 2),
- "utf-8",
- );
- await state.snapshotState(bookId, 1);
- await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
- await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 4,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2), "utf-8");
- await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
- chapter: 3,
- facts: [],
- }, null, 2), "utf-8");
- const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
- env: failingLlmEnv,
- });
- expect(exitCode).not.toBe(0);
- expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
- const next = await state.getNextChapterNumber(bookId);
- expect(next).toBe(2);
- await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1");
- });
- });
- describe("inkos analytics", () => {
- it("errors when no book exists", () => {
- const { exitCode } = runStderr(["analytics"]);
- expect(exitCode).not.toBe(0);
- });
- });
- describe("inkos review", () => {
- it("preserves the original chapter snapshot when approving review", async () => {
- const configPath = join(projectDir, "inkos.json");
- const initialized = await stat(configPath).then(() => true).catch(() => false);
- if (!initialized) run(["init"]);
- const state = new StateManager(projectDir);
- const bookId = "review-approve-cli";
- const bookDir = join(projectDir, "books", bookId);
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(chaptersDir, { recursive: true });
- await mkdir(storyDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: bookId,
- title: "Review Approve CLI",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2200,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
- await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
- await writeFile(
- join(chaptersDir, "index.json"),
- JSON.stringify([
- {
- number: 1,
- title: "Ch1",
- status: "ready-for-review",
- wordCount: 100,
- createdAt: "",
- updatedAt: "",
- auditIssues: [],
- lengthWarnings: [],
- },
- ], null, 2),
- "utf-8",
- );
- await state.snapshotState(bookId, 1);
- await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
- await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch3", "utf-8");
- const output = run(["review", "approve", bookId, "1"]);
- expect(output).toContain("Chapter 1 approved");
- await expect(
- readFile(join(storyDir, "snapshots", "1", "current_state.md"), "utf-8"),
- ).resolves.toBe("State at ch1");
- await expect(
- readFile(join(storyDir, "snapshots", "1", "pending_hooks.md"), "utf-8"),
- ).resolves.toBe("Hooks at ch1");
- const index = await state.loadChapterIndex(bookId);
- expect(index[0]?.status).toBe("approved");
- });
- });
- describe("inkos plan/compose", () => {
- beforeAll(async () => {
- const configPath = join(projectDir, "inkos.json");
- const initialized = await stat(configPath).then(() => true).catch(() => false);
- if (!initialized) run(["init"]);
- const bookDir = join(projectDir, "books", "cli-book");
- const storyDir = join(bookDir, "story");
- await mkdir(join(storyDir, "runtime"), { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "cli-book",
- title: "CLI Book",
- platform: "tomato",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 3000,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8").catch(async () => {
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
- });
- await Promise.all([
- writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
- writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- });
- it("runs plan chapter and returns the generated intent path in JSON mode", async () => {
- const output = run(["plan", "chapter", "cli-book", "--json", "--context", "Ignore the guild chase and focus on the mentor conflict."]);
- const data = JSON.parse(output);
- expect(data.bookId).toBe("cli-book");
- expect(data.chapterNumber).toBe(1);
- expect(data.intentPath).toContain("story/runtime/chapter-0001.intent.md");
- await expect(stat(join(projectDir, "books", "cli-book", data.intentPath))).resolves.toBeTruthy();
- });
- it("runs compose chapter and returns runtime artifact paths in JSON mode", async () => {
- const output = run(["compose", "chapter", "cli-book", "--json"]);
- const data = JSON.parse(output);
- expect(data.bookId).toBe("cli-book");
- expect(data.chapterNumber).toBe(1);
- expect(data.contextPath).toContain("story/runtime/chapter-0001.context.json");
- expect(data.ruleStackPath).toContain("story/runtime/chapter-0001.rule-stack.yaml");
- expect(data.tracePath).toContain("story/runtime/chapter-0001.trace.json");
- await expect(stat(join(projectDir, "books", "cli-book", data.contextPath))).resolves.toBeTruthy();
- await expect(stat(join(projectDir, "books", "cli-book", data.ruleStackPath))).resolves.toBeTruthy();
- await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
- });
- it("reuses the planned intent when compose runs without a new context", async () => {
- const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
- run(["plan", "chapter", "cli-book", "--context", plannedGoal]);
- const output = run(["compose", "chapter", "cli-book", "--json"]);
- const data = JSON.parse(output);
- const intentMarkdown = await readFile(join(projectDir, "books", "cli-book", data.intentPath), "utf-8");
- expect(data.goal).toBe(plannedGoal);
- expect(intentMarkdown).toContain(plannedGoal);
- });
- });
- describe("inkos export", () => {
- beforeAll(async () => {
- const configPath = join(projectDir, "inkos.json");
- const initialized = await stat(configPath).then(() => true).catch(() => false);
- if (!initialized) run(["init"]);
- const bookDir = join(projectDir, "books", "export-book");
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify({
- id: "export-book",
- title: "Export Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 2000,
- createdAt: "2026-03-23T00:00:00.000Z",
- updatedAt: "2026-03-23T00:00:00.000Z",
- }, null, 2),
- "utf-8",
- );
- await writeFile(
- join(bookDir, "chapters", "index.json"),
- JSON.stringify([
- {
- number: 1,
- title: "Dawn Ledger",
- status: "ready-for-review",
- wordCount: 1200,
- createdAt: "2026-03-23T00:00:00.000Z",
- updatedAt: "2026-03-23T00:00:00.000Z",
- auditIssues: [],
- },
- ], null, 2),
- "utf-8",
- );
- await writeFile(
- join(bookDir, "chapters", "0001_Dawn_Ledger.md"),
- "# 第1章 Dawn Ledger\n\n正文。\n",
- "utf-8",
- );
- });
- it("creates missing parent directories for custom output paths", async () => {
- const outputPath = join(projectDir, "exports", "nested", "book.md");
- const output = run(["export", "export-book", "--format", "md", "--output", outputPath, "--json"]);
- const data = JSON.parse(output);
- expect(data.outputPath).toBe(outputPath);
- await expect(stat(outputPath)).resolves.toBeTruthy();
- await expect(readFile(outputPath, "utf-8")).resolves.toContain("# Export Book");
- });
- });
- });
|