cli-integration.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871
  1. import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { basename, dirname, join, resolve } from "node:path";
  4. import { fileURLToPath } from "node:url";
  5. import { execFileSync } from "node:child_process";
  6. import { describe, it, expect, beforeAll, afterAll } from "vitest";
  7. import { StateManager } from "@actalk/inkos-core";
  8. const testDir = dirname(fileURLToPath(import.meta.url));
  9. const cliDir = resolve(testDir, "..", "..");
  10. const cliEntry = resolve(cliDir, "dist", "index.js");
  11. let projectDir: string;
  12. function run(args: string[], options?: { env?: Record<string, string> }): string {
  13. return execFileSync("node", [cliEntry, ...args], {
  14. cwd: projectDir,
  15. encoding: "utf-8",
  16. env: {
  17. ...process.env,
  18. // Prevent global config from leaking into tests
  19. HOME: projectDir,
  20. ...options?.env,
  21. },
  22. timeout: 10_000,
  23. });
  24. }
  25. function runStderr(args: string[], options?: { env?: Record<string, string> }): { stdout: string; stderr: string; exitCode: number } {
  26. try {
  27. const stdout = execFileSync("node", [cliEntry, ...args], {
  28. cwd: projectDir,
  29. encoding: "utf-8",
  30. env: { ...process.env, HOME: projectDir, ...options?.env },
  31. timeout: 10_000,
  32. });
  33. return { stdout, stderr: "", exitCode: 0 };
  34. } catch (e: unknown) {
  35. const err = e as { stdout: string; stderr: string; status: number };
  36. return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
  37. }
  38. }
  39. const failingLlmEnv = {
  40. INKOS_LLM_PROVIDER: "openai",
  41. INKOS_LLM_BASE_URL: "http://127.0.0.1:9/v1",
  42. INKOS_LLM_MODEL: "test-model",
  43. INKOS_LLM_API_KEY: "test-key",
  44. };
  45. describe("CLI integration", () => {
  46. beforeAll(async () => {
  47. projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
  48. });
  49. afterAll(async () => {
  50. await rm(projectDir, { recursive: true, force: true });
  51. });
  52. describe("inkos --version", () => {
  53. it("prints version number", () => {
  54. const output = run(["--version"]);
  55. expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
  56. });
  57. });
  58. describe("inkos --help", () => {
  59. it("prints help with command list", () => {
  60. const output = run(["--help"]);
  61. expect(output).toContain("inkos");
  62. expect(output).toContain("init");
  63. expect(output).toContain("book");
  64. expect(output).toContain("write");
  65. });
  66. });
  67. describe("inkos init", () => {
  68. it("initializes project in current directory", () => {
  69. const output = run(["init"]);
  70. expect(output).toContain("Project initialized");
  71. });
  72. it("creates inkos.json with correct structure", async () => {
  73. const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
  74. const config = JSON.parse(raw);
  75. expect(config.llm).toBeDefined();
  76. expect(config.llm.provider).toBeDefined();
  77. expect(config.llm.model).toBeDefined();
  78. expect(config.daemon).toBeDefined();
  79. expect(config.notify).toEqual([]);
  80. });
  81. it("creates .env file", async () => {
  82. const envContent = await readFile(join(projectDir, ".env"), "utf-8");
  83. expect(envContent).toContain("INKOS_LLM_API_KEY");
  84. });
  85. it("creates .gitignore", async () => {
  86. const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8");
  87. expect(gitignore).toContain(".env");
  88. });
  89. it("creates Node version hints for sqlite-backed memory features", async () => {
  90. await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toContain("22");
  91. await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toContain("22");
  92. });
  93. it("creates books/ and radar/ directories", async () => {
  94. const booksStat = await stat(join(projectDir, "books"));
  95. expect(booksStat.isDirectory()).toBe(true);
  96. const radarStat = await stat(join(projectDir, "radar"));
  97. expect(radarStat.isDirectory()).toBe(true);
  98. });
  99. });
  100. describe("inkos init <name>", () => {
  101. it("creates project in subdirectory", () => {
  102. const output = run(["init", "subproject"]);
  103. expect(output).toContain("Project initialized");
  104. });
  105. it("creates inkos.json in subdirectory", async () => {
  106. const raw = await readFile(join(projectDir, "subproject", "inkos.json"), "utf-8");
  107. const config = JSON.parse(raw);
  108. expect(config.name).toBe("subproject");
  109. });
  110. it("supports absolute project paths instead of nesting them under cwd", async () => {
  111. const absoluteDir = await mkdtemp(join(tmpdir(), "inkos-cli-abs-init-"));
  112. try {
  113. const output = run(["init", absoluteDir]);
  114. expect(output).toContain(`Project initialized at ${absoluteDir}`);
  115. const raw = await readFile(join(absoluteDir, "inkos.json"), "utf-8");
  116. const config = JSON.parse(raw);
  117. expect(config.name).toBe(basename(absoluteDir));
  118. } finally {
  119. await rm(absoluteDir, { recursive: true, force: true });
  120. }
  121. });
  122. it("prints English next steps when initialized with --lang en", async () => {
  123. const englishDir = await mkdtemp(join(tmpdir(), "inkos-cli-en-init-"));
  124. try {
  125. const output = run(["init", englishDir, "--lang", "en"]);
  126. expect(output).toContain("Project initialized");
  127. expect(output).toContain("inkos book create --title 'My Novel'");
  128. expect(output).not.toContain("我的小说");
  129. } finally {
  130. await rm(englishDir, { recursive: true, force: true });
  131. }
  132. });
  133. });
  134. describe("inkos config set", () => {
  135. it("sets a known config value", () => {
  136. const output = run(["config", "set", "llm.provider", "anthropic"]);
  137. expect(output).toContain("Set llm.provider = anthropic");
  138. });
  139. it("sets a nested config value", async () => {
  140. run(["config", "set", "llm.model", "gpt-5"]);
  141. const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
  142. const config = JSON.parse(raw);
  143. expect(config.llm.model).toBe("gpt-5");
  144. });
  145. it("rejects unknown config keys", () => {
  146. expect(() => {
  147. run(["config", "set", "custom.nested.key", "value"]);
  148. }).toThrow();
  149. });
  150. it("sets input governance mode", async () => {
  151. const output = run(["config", "set", "inputGovernanceMode", "v2"]);
  152. expect(output).toContain("Set inputGovernanceMode = v2");
  153. const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
  154. const config = JSON.parse(raw);
  155. expect(config.inputGovernanceMode).toBe("v2");
  156. });
  157. });
  158. describe("inkos config show", () => {
  159. it("shows current config as JSON", () => {
  160. const output = run(["config", "show"]);
  161. const config = JSON.parse(output);
  162. expect(config.llm.model).toBe("gpt-5");
  163. });
  164. });
  165. describe("inkos config set-model", () => {
  166. it("rejects raw API keys passed to --api-key-env", async () => {
  167. const { exitCode, stderr } = runStderr([
  168. "config",
  169. "set-model",
  170. "writer",
  171. "gpt-4-turbo",
  172. "--provider",
  173. "custom",
  174. "--base-url",
  175. "https://poloai.top/v1",
  176. "--api-key-env",
  177. "sk-test-direct-key",
  178. ]);
  179. expect(exitCode).not.toBe(0);
  180. expect(stderr).toContain("--api-key-env expects an environment variable name");
  181. const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
  182. const config = JSON.parse(raw);
  183. expect(config.modelOverrides).toBeUndefined();
  184. });
  185. });
  186. describe("inkos book list", () => {
  187. it("shows no books in empty project", () => {
  188. const output = run(["book", "list"]);
  189. expect(output).toContain("No books found");
  190. });
  191. it("returns empty array in JSON mode", () => {
  192. const output = run(["book", "list", "--json"]);
  193. const data = JSON.parse(output);
  194. expect(data.books).toEqual([]);
  195. });
  196. });
  197. describe("inkos book create", () => {
  198. it("removes stale incomplete book directories before retrying create", async () => {
  199. try {
  200. await stat(join(projectDir, "inkos.json"));
  201. } catch {
  202. run(["init"]);
  203. }
  204. const bookId = "stale-book";
  205. const staleDir = join(projectDir, "books", bookId);
  206. await mkdir(join(staleDir, "story"), { recursive: true });
  207. await writeFile(join(staleDir, "book.json"), JSON.stringify({
  208. id: bookId,
  209. title: "Stale Book",
  210. }, null, 2));
  211. await writeFile(join(staleDir, "story", "current_state.md"), "# stale\n", "utf-8");
  212. const { exitCode, stderr } = runStderr([
  213. "book",
  214. "create",
  215. "--title",
  216. "stale book",
  217. ], {
  218. env: failingLlmEnv,
  219. });
  220. expect(exitCode).not.toBe(0);
  221. expect(stderr).toContain("Failed to create book");
  222. await expect(stat(staleDir)).rejects.toThrow();
  223. });
  224. });
  225. describe("inkos status", () => {
  226. it("shows project status with zero books", () => {
  227. const output = run(["status"]);
  228. expect(output).toContain("Books: 0");
  229. });
  230. it("returns JSON with --json flag", () => {
  231. const output = run(["status", "--json"]);
  232. const data = JSON.parse(output);
  233. expect(data.project).toBeDefined();
  234. expect(data.books).toEqual([]);
  235. });
  236. it("errors for nonexistent book", () => {
  237. const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
  238. expect(exitCode).not.toBe(0);
  239. });
  240. it("shows English chapter counts in words for chapter rows", async () => {
  241. const bookDir = join(projectDir, "books", "english-status");
  242. await mkdir(join(bookDir, "chapters"), { recursive: true });
  243. await writeFile(
  244. join(bookDir, "book.json"),
  245. JSON.stringify({
  246. id: "english-status",
  247. title: "English Status Book",
  248. platform: "other",
  249. genre: "other",
  250. status: "active",
  251. targetChapters: 10,
  252. chapterWordCount: 2200,
  253. language: "en",
  254. createdAt: "2026-03-22T00:00:00.000Z",
  255. updatedAt: "2026-03-22T00:00:00.000Z",
  256. }, null, 2),
  257. "utf-8",
  258. );
  259. await writeFile(
  260. join(bookDir, "chapters", "index.json"),
  261. JSON.stringify([
  262. {
  263. number: 1,
  264. title: "A Quiet Sky",
  265. status: "ready-for-review",
  266. wordCount: 7,
  267. createdAt: "2026-03-22T00:00:00.000Z",
  268. updatedAt: "2026-03-22T00:00:00.000Z",
  269. auditIssues: [],
  270. lengthWarnings: [],
  271. },
  272. ], null, 2),
  273. "utf-8",
  274. );
  275. const output = run(["status", "english-status", "--chapters"]);
  276. expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
  277. expect(output).not.toContain("7字");
  278. });
  279. it("shows degraded chapter counts and issues explicitly", async () => {
  280. const bookDir = join(projectDir, "books", "degraded-status");
  281. await mkdir(join(bookDir, "chapters"), { recursive: true });
  282. await writeFile(
  283. join(bookDir, "book.json"),
  284. JSON.stringify({
  285. id: "degraded-status",
  286. title: "Degraded Status Book",
  287. platform: "other",
  288. genre: "other",
  289. status: "active",
  290. targetChapters: 10,
  291. chapterWordCount: 2200,
  292. createdAt: "2026-04-01T00:00:00.000Z",
  293. updatedAt: "2026-04-01T00:00:00.000Z",
  294. }, null, 2),
  295. "utf-8",
  296. );
  297. await writeFile(
  298. join(bookDir, "chapters", "index.json"),
  299. JSON.stringify([
  300. {
  301. number: 1,
  302. title: "Broken State",
  303. status: "state-degraded",
  304. wordCount: 1800,
  305. createdAt: "2026-04-01T00:00:00.000Z",
  306. updatedAt: "2026-04-01T00:00:00.000Z",
  307. auditIssues: ["[warning] state validation still failed after retry"],
  308. lengthWarnings: [],
  309. },
  310. ], null, 2),
  311. "utf-8",
  312. );
  313. const output = run(["status", "degraded-status", "--chapters"]);
  314. expect(output).toContain("Degraded: 1");
  315. expect(output).toContain('Ch.1 "Broken State" | 1800字 | state-degraded');
  316. expect(output).toContain("[warning] state validation still failed after retry");
  317. const json = JSON.parse(run(["status", "degraded-status", "--json"]));
  318. expect(json.books[0]?.degraded).toBe(1);
  319. });
  320. it("shows a migration hint for legacy pre-v0.6 books", async () => {
  321. const bookDir = join(projectDir, "books", "legacy-status-hint");
  322. const storyDir = join(bookDir, "story");
  323. await mkdir(join(bookDir, "chapters"), { recursive: true });
  324. await mkdir(storyDir, { recursive: true });
  325. await writeFile(
  326. join(bookDir, "book.json"),
  327. JSON.stringify({
  328. id: "legacy-status-hint",
  329. title: "Legacy Status Hint",
  330. platform: "other",
  331. genre: "other",
  332. status: "active",
  333. targetChapters: 10,
  334. chapterWordCount: 2200,
  335. createdAt: "2026-03-22T00:00:00.000Z",
  336. updatedAt: "2026-03-22T00:00:00.000Z",
  337. }, null, 2),
  338. "utf-8",
  339. );
  340. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  341. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  342. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  343. const output = run(["status", "legacy-status-hint"]);
  344. expect(output).toContain("legacy format");
  345. });
  346. it("reports persisted chapter file count instead of runtime progress when state runs ahead", async () => {
  347. const bookId = "ahead-status";
  348. const bookDir = join(projectDir, "books", bookId);
  349. const chaptersDir = join(bookDir, "chapters");
  350. const stateDir = join(bookDir, "story", "state");
  351. await mkdir(chaptersDir, { recursive: true });
  352. await mkdir(stateDir, { recursive: true });
  353. await writeFile(
  354. join(bookDir, "book.json"),
  355. JSON.stringify({
  356. id: bookId,
  357. title: "Ahead Status Book",
  358. platform: "other",
  359. genre: "other",
  360. status: "active",
  361. targetChapters: 10,
  362. chapterWordCount: 2200,
  363. createdAt: "2026-03-29T00:00:00.000Z",
  364. updatedAt: "2026-03-29T00:00:00.000Z",
  365. }, null, 2),
  366. "utf-8",
  367. );
  368. await writeFile(join(chaptersDir, "0001_First.md"), "# 第1章 First\n\nOnly persisted chapter.", "utf-8");
  369. await writeFile(
  370. join(chaptersDir, "index.json"),
  371. JSON.stringify([
  372. {
  373. number: 1,
  374. title: "First",
  375. status: "ready-for-review",
  376. wordCount: 42,
  377. createdAt: "2026-03-29T00:00:00.000Z",
  378. updatedAt: "2026-03-29T00:00:00.000Z",
  379. auditIssues: [],
  380. lengthWarnings: [],
  381. },
  382. ], null, 2),
  383. "utf-8",
  384. );
  385. await Promise.all([
  386. writeFile(
  387. join(stateDir, "manifest.json"),
  388. JSON.stringify({
  389. schemaVersion: 2,
  390. language: "zh",
  391. lastAppliedChapter: 4,
  392. projectionVersion: 1,
  393. migrationWarnings: [],
  394. }, null, 2),
  395. "utf-8",
  396. ),
  397. writeFile(
  398. join(stateDir, "current_state.json"),
  399. JSON.stringify({
  400. chapter: 4,
  401. facts: [],
  402. }, null, 2),
  403. "utf-8",
  404. ),
  405. writeFile(join(stateDir, "hooks.json"), JSON.stringify({ hooks: [] }, null, 2), "utf-8"),
  406. writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({ rows: [] }, null, 2), "utf-8"),
  407. ]);
  408. const output = run(["status", bookId]);
  409. expect(output).toContain("Chapters: 1 / 10");
  410. expect(output).not.toContain("Chapters: 4 / 10");
  411. const json = JSON.parse(run(["status", bookId, "--json"]));
  412. expect(json.books[0]?.chapters).toBe(1);
  413. });
  414. });
  415. describe("inkos doctor", () => {
  416. it("checks environment health", () => {
  417. const { stdout } = runStderr(["doctor"]);
  418. expect(stdout).toContain("InkOS Doctor");
  419. expect(stdout).toContain("Node.js >= 20");
  420. expect(stdout).toContain("SQLite memory index");
  421. expect(stdout).toContain("inkos.json");
  422. });
  423. it("repairs missing node runtime pin files for old projects", async () => {
  424. await stat(join(projectDir, "inkos.json")).catch(() => {
  425. run(["init"]);
  426. });
  427. await rm(join(projectDir, ".nvmrc"), { force: true });
  428. await rm(join(projectDir, ".node-version"), { force: true });
  429. const before = runStderr(["doctor"]);
  430. expect(before.stdout).toContain("Node runtime pin files");
  431. expect(before.stdout).toContain(".nvmrc");
  432. expect(before.stdout).toContain(".node-version");
  433. const repaired = runStderr(["doctor", "--repair-node-runtime"]);
  434. expect(repaired.stdout).toContain("Node runtime pin files repaired");
  435. expect(repaired.stdout).toContain(".nvmrc");
  436. expect(repaired.stdout).toContain(".node-version");
  437. await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
  438. await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
  439. });
  440. it("treats localhost OpenAI-compatible endpoints as API-key optional", async () => {
  441. await stat(join(projectDir, "inkos.json")).catch(() => {
  442. run(["init"]);
  443. });
  444. const configPath = join(projectDir, "inkos.json");
  445. const envPath = join(projectDir, ".env");
  446. const originalConfig = await readFile(configPath, "utf-8");
  447. const originalEnv = await readFile(envPath, "utf-8");
  448. try {
  449. const config = JSON.parse(originalConfig);
  450. config.llm.provider = "openai";
  451. config.llm.baseUrl = "http://127.0.0.1:11434/v1";
  452. config.llm.model = "gpt-oss:20b";
  453. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  454. await writeFile(envPath, [
  455. "INKOS_LLM_PROVIDER=openai",
  456. "INKOS_LLM_BASE_URL=http://127.0.0.1:11434/v1",
  457. "INKOS_LLM_MODEL=gpt-oss:20b",
  458. "",
  459. ].join("\n"), "utf-8");
  460. const { stdout } = runStderr(["doctor"], {
  461. env: { INKOS_LLM_API_KEY: "" },
  462. });
  463. expect(stdout).toContain("LLM API Key");
  464. expect(stdout).toContain("Optional for local/self-hosted endpoint");
  465. expect(stdout).toContain("LLM Config");
  466. expect(stdout).not.toContain("No LLM config available");
  467. } finally {
  468. await writeFile(configPath, originalConfig, "utf-8");
  469. await writeFile(envPath, originalEnv, "utf-8");
  470. }
  471. });
  472. it("reports legacy books in the version migration check", async () => {
  473. const bookDir = join(projectDir, "books", "legacy-doctor-hint");
  474. const storyDir = join(bookDir, "story");
  475. await mkdir(join(bookDir, "chapters"), { recursive: true });
  476. await mkdir(storyDir, { recursive: true });
  477. await writeFile(
  478. join(bookDir, "book.json"),
  479. JSON.stringify({
  480. id: "legacy-doctor-hint",
  481. title: "Legacy Doctor Hint",
  482. platform: "other",
  483. genre: "other",
  484. status: "active",
  485. targetChapters: 10,
  486. chapterWordCount: 2200,
  487. createdAt: "2026-03-22T00:00:00.000Z",
  488. updatedAt: "2026-03-22T00:00:00.000Z",
  489. }, null, 2),
  490. "utf-8",
  491. );
  492. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  493. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  494. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  495. const { stdout } = runStderr(["doctor"]);
  496. expect(stdout).toContain("Version Migration");
  497. expect(stdout).toContain("legacy format");
  498. });
  499. });
  500. describe("inkos write", () => {
  501. it("warns before writing when the target book still uses legacy format", async () => {
  502. const bookDir = join(projectDir, "books", "legacy-write-hint");
  503. const storyDir = join(bookDir, "story");
  504. await mkdir(join(bookDir, "chapters"), { recursive: true });
  505. await mkdir(storyDir, { recursive: true });
  506. await writeFile(
  507. join(bookDir, "book.json"),
  508. JSON.stringify({
  509. id: "legacy-write-hint",
  510. title: "Legacy Write Hint",
  511. platform: "other",
  512. genre: "other",
  513. status: "active",
  514. targetChapters: 10,
  515. chapterWordCount: 2200,
  516. createdAt: "2026-03-22T00:00:00.000Z",
  517. updatedAt: "2026-03-22T00:00:00.000Z",
  518. }, null, 2),
  519. "utf-8",
  520. );
  521. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  522. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  523. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  524. const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], {
  525. env: failingLlmEnv,
  526. });
  527. expect(`${stdout}\n${stderr}`).toContain("legacy format");
  528. });
  529. it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
  530. const state = new StateManager(projectDir);
  531. const bookId = "rewrite-cli";
  532. const bookDir = join(projectDir, "books", bookId);
  533. const storyDir = join(bookDir, "story");
  534. const chaptersDir = join(bookDir, "chapters");
  535. const stateDir = join(storyDir, "state");
  536. await mkdir(chaptersDir, { recursive: true });
  537. await mkdir(stateDir, { recursive: true });
  538. await writeFile(
  539. join(bookDir, "book.json"),
  540. JSON.stringify({
  541. id: bookId,
  542. title: "Rewrite CLI",
  543. platform: "other",
  544. genre: "other",
  545. status: "active",
  546. targetChapters: 10,
  547. chapterWordCount: 2200,
  548. createdAt: "2026-03-22T00:00:00.000Z",
  549. updatedAt: "2026-03-22T00:00:00.000Z",
  550. }, null, 2),
  551. "utf-8",
  552. );
  553. await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
  554. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
  555. await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
  556. await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
  557. await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8");
  558. await writeFile(
  559. join(chaptersDir, "index.json"),
  560. JSON.stringify([
  561. { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  562. { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  563. { number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  564. ], null, 2),
  565. "utf-8",
  566. );
  567. await state.snapshotState(bookId, 1);
  568. await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
  569. await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
  570. schemaVersion: 2,
  571. language: "en",
  572. lastAppliedChapter: 4,
  573. projectionVersion: 1,
  574. migrationWarnings: [],
  575. }, null, 2), "utf-8");
  576. await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
  577. chapter: 3,
  578. facts: [],
  579. }, null, 2), "utf-8");
  580. const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
  581. env: failingLlmEnv,
  582. });
  583. expect(exitCode).not.toBe(0);
  584. expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
  585. const next = await state.getNextChapterNumber(bookId);
  586. expect(next).toBe(2);
  587. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1");
  588. });
  589. });
  590. describe("inkos analytics", () => {
  591. it("errors when no book exists", () => {
  592. const { exitCode } = runStderr(["analytics"]);
  593. expect(exitCode).not.toBe(0);
  594. });
  595. });
  596. describe("inkos review", () => {
  597. it("preserves the original chapter snapshot when approving review", async () => {
  598. const configPath = join(projectDir, "inkos.json");
  599. const initialized = await stat(configPath).then(() => true).catch(() => false);
  600. if (!initialized) run(["init"]);
  601. const state = new StateManager(projectDir);
  602. const bookId = "review-approve-cli";
  603. const bookDir = join(projectDir, "books", bookId);
  604. const storyDir = join(bookDir, "story");
  605. const chaptersDir = join(bookDir, "chapters");
  606. await mkdir(chaptersDir, { recursive: true });
  607. await mkdir(storyDir, { recursive: true });
  608. await writeFile(
  609. join(bookDir, "book.json"),
  610. JSON.stringify({
  611. id: bookId,
  612. title: "Review Approve CLI",
  613. platform: "other",
  614. genre: "other",
  615. status: "active",
  616. targetChapters: 10,
  617. chapterWordCount: 2200,
  618. createdAt: "2026-03-22T00:00:00.000Z",
  619. updatedAt: "2026-03-22T00:00:00.000Z",
  620. }, null, 2),
  621. "utf-8",
  622. );
  623. await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
  624. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
  625. await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
  626. await writeFile(
  627. join(chaptersDir, "index.json"),
  628. JSON.stringify([
  629. {
  630. number: 1,
  631. title: "Ch1",
  632. status: "ready-for-review",
  633. wordCount: 100,
  634. createdAt: "",
  635. updatedAt: "",
  636. auditIssues: [],
  637. lengthWarnings: [],
  638. },
  639. ], null, 2),
  640. "utf-8",
  641. );
  642. await state.snapshotState(bookId, 1);
  643. await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
  644. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch3", "utf-8");
  645. const output = run(["review", "approve", bookId, "1"]);
  646. expect(output).toContain("Chapter 1 approved");
  647. await expect(
  648. readFile(join(storyDir, "snapshots", "1", "current_state.md"), "utf-8"),
  649. ).resolves.toBe("State at ch1");
  650. await expect(
  651. readFile(join(storyDir, "snapshots", "1", "pending_hooks.md"), "utf-8"),
  652. ).resolves.toBe("Hooks at ch1");
  653. const index = await state.loadChapterIndex(bookId);
  654. expect(index[0]?.status).toBe("approved");
  655. });
  656. });
  657. describe("inkos plan/compose", () => {
  658. beforeAll(async () => {
  659. const configPath = join(projectDir, "inkos.json");
  660. const initialized = await stat(configPath).then(() => true).catch(() => false);
  661. if (!initialized) run(["init"]);
  662. const bookDir = join(projectDir, "books", "cli-book");
  663. const storyDir = join(bookDir, "story");
  664. await mkdir(join(storyDir, "runtime"), { recursive: true });
  665. await writeFile(
  666. join(bookDir, "book.json"),
  667. JSON.stringify({
  668. id: "cli-book",
  669. title: "CLI Book",
  670. platform: "tomato",
  671. genre: "other",
  672. status: "active",
  673. targetChapters: 20,
  674. chapterWordCount: 3000,
  675. createdAt: "2026-03-22T00:00:00.000Z",
  676. updatedAt: "2026-03-22T00:00:00.000Z",
  677. }, null, 2),
  678. "utf-8",
  679. );
  680. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8").catch(async () => {
  681. await mkdir(join(bookDir, "chapters"), { recursive: true });
  682. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  683. });
  684. await Promise.all([
  685. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
  686. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  687. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  688. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  689. writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
  690. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  691. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  692. ]);
  693. });
  694. it("runs plan chapter and returns the generated intent path in JSON mode", async () => {
  695. const output = run(["plan", "chapter", "cli-book", "--json", "--context", "Ignore the guild chase and focus on the mentor conflict."]);
  696. const data = JSON.parse(output);
  697. expect(data.bookId).toBe("cli-book");
  698. expect(data.chapterNumber).toBe(1);
  699. expect(data.intentPath).toContain("story/runtime/chapter-0001.intent.md");
  700. await expect(stat(join(projectDir, "books", "cli-book", data.intentPath))).resolves.toBeTruthy();
  701. });
  702. it("runs compose chapter and returns runtime artifact paths in JSON mode", async () => {
  703. const output = run(["compose", "chapter", "cli-book", "--json"]);
  704. const data = JSON.parse(output);
  705. expect(data.bookId).toBe("cli-book");
  706. expect(data.chapterNumber).toBe(1);
  707. expect(data.contextPath).toContain("story/runtime/chapter-0001.context.json");
  708. expect(data.ruleStackPath).toContain("story/runtime/chapter-0001.rule-stack.yaml");
  709. expect(data.tracePath).toContain("story/runtime/chapter-0001.trace.json");
  710. await expect(stat(join(projectDir, "books", "cli-book", data.contextPath))).resolves.toBeTruthy();
  711. await expect(stat(join(projectDir, "books", "cli-book", data.ruleStackPath))).resolves.toBeTruthy();
  712. await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
  713. });
  714. it("reuses the planned intent when compose runs without a new context", async () => {
  715. const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
  716. run(["plan", "chapter", "cli-book", "--context", plannedGoal]);
  717. const output = run(["compose", "chapter", "cli-book", "--json"]);
  718. const data = JSON.parse(output);
  719. const intentMarkdown = await readFile(join(projectDir, "books", "cli-book", data.intentPath), "utf-8");
  720. expect(data.goal).toBe(plannedGoal);
  721. expect(intentMarkdown).toContain(plannedGoal);
  722. });
  723. });
  724. describe("inkos export", () => {
  725. beforeAll(async () => {
  726. const configPath = join(projectDir, "inkos.json");
  727. const initialized = await stat(configPath).then(() => true).catch(() => false);
  728. if (!initialized) run(["init"]);
  729. const bookDir = join(projectDir, "books", "export-book");
  730. await mkdir(join(bookDir, "chapters"), { recursive: true });
  731. await writeFile(
  732. join(bookDir, "book.json"),
  733. JSON.stringify({
  734. id: "export-book",
  735. title: "Export Book",
  736. platform: "tomato",
  737. genre: "xuanhuan",
  738. status: "active",
  739. targetChapters: 10,
  740. chapterWordCount: 2000,
  741. createdAt: "2026-03-23T00:00:00.000Z",
  742. updatedAt: "2026-03-23T00:00:00.000Z",
  743. }, null, 2),
  744. "utf-8",
  745. );
  746. await writeFile(
  747. join(bookDir, "chapters", "index.json"),
  748. JSON.stringify([
  749. {
  750. number: 1,
  751. title: "Dawn Ledger",
  752. status: "ready-for-review",
  753. wordCount: 1200,
  754. createdAt: "2026-03-23T00:00:00.000Z",
  755. updatedAt: "2026-03-23T00:00:00.000Z",
  756. auditIssues: [],
  757. },
  758. ], null, 2),
  759. "utf-8",
  760. );
  761. await writeFile(
  762. join(bookDir, "chapters", "0001_Dawn_Ledger.md"),
  763. "# 第1章 Dawn Ledger\n\n正文。\n",
  764. "utf-8",
  765. );
  766. });
  767. it("creates missing parent directories for custom output paths", async () => {
  768. const outputPath = join(projectDir, "exports", "nested", "book.md");
  769. const output = run(["export", "export-book", "--format", "md", "--output", outputPath, "--json"]);
  770. const data = JSON.parse(output);
  771. expect(data.outputPath).toBe(outputPath);
  772. await expect(stat(outputPath)).resolves.toBeTruthy();
  773. await expect(readFile(outputPath, "utf-8")).resolves.toContain("# Export Book");
  774. });
  775. });
  776. });