cli-integration.test.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  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 interact", () => {
  166. it("returns structured JSON for shared interaction mode switches", async () => {
  167. const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
  168. if (!initialized) run(["init"]);
  169. await writeFile(
  170. join(projectDir, ".env"),
  171. Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
  172. "utf-8",
  173. );
  174. const output = run(["interact", "--json", "--message", "切换到全自动"]);
  175. const data = JSON.parse(output);
  176. expect(data.request.intent).toBe("switch_mode");
  177. expect(data.request.mode).toBe("auto");
  178. expect(data.session.automationMode).toBe("auto");
  179. });
  180. it("binds the requested book when interact is called with --book", async () => {
  181. const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
  182. if (!initialized) run(["init"]);
  183. await writeFile(
  184. join(projectDir, ".env"),
  185. Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
  186. "utf-8",
  187. );
  188. const state = new StateManager(projectDir);
  189. await state.saveBookConfig("harbor", {
  190. id: "harbor",
  191. title: "Harbor",
  192. platform: "tomato",
  193. genre: "other",
  194. status: "active",
  195. targetChapters: 20,
  196. chapterWordCount: 3000,
  197. createdAt: "2026-04-07T00:00:00.000Z",
  198. updatedAt: "2026-04-07T00:00:00.000Z",
  199. });
  200. const output = run(["interact", "--json", "--book", "harbor", "--message", "explain status"]);
  201. const data = JSON.parse(output);
  202. expect(data.session.activeBookId).toBe("harbor");
  203. });
  204. });
  205. describe("inkos config set-model", () => {
  206. it("rejects raw API keys passed to --api-key-env", async () => {
  207. const { exitCode, stderr } = runStderr([
  208. "config",
  209. "set-model",
  210. "writer",
  211. "gpt-4-turbo",
  212. "--provider",
  213. "custom",
  214. "--base-url",
  215. "https://poloai.top/v1",
  216. "--api-key-env",
  217. "sk-test-direct-key",
  218. ]);
  219. expect(exitCode).not.toBe(0);
  220. expect(stderr).toContain("--api-key-env expects an environment variable name");
  221. const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
  222. const config = JSON.parse(raw);
  223. expect(config.modelOverrides).toBeUndefined();
  224. });
  225. });
  226. describe("inkos book list", () => {
  227. it("shows no books in empty project", () => {
  228. const output = run(["book", "list"]);
  229. expect(output).toContain("No books found");
  230. });
  231. it("returns empty array in JSON mode", () => {
  232. const output = run(["book", "list", "--json"]);
  233. const data = JSON.parse(output);
  234. expect(data.books).toEqual([]);
  235. });
  236. });
  237. describe("inkos book create", () => {
  238. it("removes stale incomplete book directories before retrying create", async () => {
  239. try {
  240. await stat(join(projectDir, "inkos.json"));
  241. } catch {
  242. run(["init"]);
  243. }
  244. const bookId = "stale-book";
  245. const staleDir = join(projectDir, "books", bookId);
  246. await mkdir(join(staleDir, "story"), { recursive: true });
  247. await writeFile(join(staleDir, "book.json"), JSON.stringify({
  248. id: bookId,
  249. title: "Stale Book",
  250. }, null, 2));
  251. await writeFile(join(staleDir, "story", "current_state.md"), "# stale\n", "utf-8");
  252. const { exitCode, stderr } = runStderr([
  253. "book",
  254. "create",
  255. "--title",
  256. "stale book",
  257. ], {
  258. env: failingLlmEnv,
  259. });
  260. expect(exitCode).not.toBe(0);
  261. expect(stderr).toContain("Failed to create book");
  262. await expect(stat(staleDir)).rejects.toThrow();
  263. });
  264. });
  265. describe("inkos status", () => {
  266. it("shows project status with zero books", () => {
  267. const output = run(["status"]);
  268. expect(output).toContain("Books: 0");
  269. });
  270. it("returns JSON with --json flag", () => {
  271. const output = run(["status", "--json"]);
  272. const data = JSON.parse(output);
  273. expect(data.project).toBeDefined();
  274. expect(data.books).toEqual([]);
  275. });
  276. it("errors for nonexistent book", () => {
  277. const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
  278. expect(exitCode).not.toBe(0);
  279. });
  280. it("shows English chapter counts in words for chapter rows", async () => {
  281. const bookDir = join(projectDir, "books", "english-status");
  282. await mkdir(join(bookDir, "chapters"), { recursive: true });
  283. await writeFile(
  284. join(bookDir, "book.json"),
  285. JSON.stringify({
  286. id: "english-status",
  287. title: "English Status Book",
  288. platform: "other",
  289. genre: "other",
  290. status: "active",
  291. targetChapters: 10,
  292. chapterWordCount: 2200,
  293. language: "en",
  294. createdAt: "2026-03-22T00:00:00.000Z",
  295. updatedAt: "2026-03-22T00:00:00.000Z",
  296. }, null, 2),
  297. "utf-8",
  298. );
  299. await writeFile(
  300. join(bookDir, "chapters", "index.json"),
  301. JSON.stringify([
  302. {
  303. number: 1,
  304. title: "A Quiet Sky",
  305. status: "ready-for-review",
  306. wordCount: 7,
  307. createdAt: "2026-03-22T00:00:00.000Z",
  308. updatedAt: "2026-03-22T00:00:00.000Z",
  309. auditIssues: [],
  310. lengthWarnings: [],
  311. },
  312. ], null, 2),
  313. "utf-8",
  314. );
  315. const output = run(["status", "english-status", "--chapters"]);
  316. expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
  317. expect(output).not.toContain("7字");
  318. });
  319. it("shows degraded chapter counts and issues explicitly", async () => {
  320. const bookDir = join(projectDir, "books", "degraded-status");
  321. await mkdir(join(bookDir, "chapters"), { recursive: true });
  322. await writeFile(
  323. join(bookDir, "book.json"),
  324. JSON.stringify({
  325. id: "degraded-status",
  326. title: "Degraded Status Book",
  327. platform: "other",
  328. genre: "other",
  329. status: "active",
  330. targetChapters: 10,
  331. chapterWordCount: 2200,
  332. createdAt: "2026-04-01T00:00:00.000Z",
  333. updatedAt: "2026-04-01T00:00:00.000Z",
  334. }, null, 2),
  335. "utf-8",
  336. );
  337. await writeFile(
  338. join(bookDir, "chapters", "index.json"),
  339. JSON.stringify([
  340. {
  341. number: 1,
  342. title: "Broken State",
  343. status: "state-degraded",
  344. wordCount: 1800,
  345. createdAt: "2026-04-01T00:00:00.000Z",
  346. updatedAt: "2026-04-01T00:00:00.000Z",
  347. auditIssues: ["[warning] state validation still failed after retry"],
  348. lengthWarnings: [],
  349. },
  350. ], null, 2),
  351. "utf-8",
  352. );
  353. const output = run(["status", "degraded-status", "--chapters"]);
  354. expect(output).toContain("Degraded: 1");
  355. expect(output).toContain('Ch.1 "Broken State" | 1800字 | state-degraded');
  356. expect(output).toContain("[warning] state validation still failed after retry");
  357. const json = JSON.parse(run(["status", "degraded-status", "--json"]));
  358. expect(json.books[0]?.degraded).toBe(1);
  359. });
  360. it("shows a migration hint for legacy pre-v0.6 books", async () => {
  361. const bookDir = join(projectDir, "books", "legacy-status-hint");
  362. const storyDir = join(bookDir, "story");
  363. await mkdir(join(bookDir, "chapters"), { recursive: true });
  364. await mkdir(storyDir, { recursive: true });
  365. await writeFile(
  366. join(bookDir, "book.json"),
  367. JSON.stringify({
  368. id: "legacy-status-hint",
  369. title: "Legacy Status Hint",
  370. platform: "other",
  371. genre: "other",
  372. status: "active",
  373. targetChapters: 10,
  374. chapterWordCount: 2200,
  375. createdAt: "2026-03-22T00:00:00.000Z",
  376. updatedAt: "2026-03-22T00:00:00.000Z",
  377. }, null, 2),
  378. "utf-8",
  379. );
  380. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  381. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  382. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  383. const output = run(["status", "legacy-status-hint"]);
  384. expect(output).toContain("legacy format");
  385. });
  386. it("reports persisted chapter file count instead of runtime progress when state runs ahead", async () => {
  387. const bookId = "ahead-status";
  388. const bookDir = join(projectDir, "books", bookId);
  389. const chaptersDir = join(bookDir, "chapters");
  390. const stateDir = join(bookDir, "story", "state");
  391. await mkdir(chaptersDir, { recursive: true });
  392. await mkdir(stateDir, { recursive: true });
  393. await writeFile(
  394. join(bookDir, "book.json"),
  395. JSON.stringify({
  396. id: bookId,
  397. title: "Ahead Status Book",
  398. platform: "other",
  399. genre: "other",
  400. status: "active",
  401. targetChapters: 10,
  402. chapterWordCount: 2200,
  403. createdAt: "2026-03-29T00:00:00.000Z",
  404. updatedAt: "2026-03-29T00:00:00.000Z",
  405. }, null, 2),
  406. "utf-8",
  407. );
  408. await writeFile(join(chaptersDir, "0001_First.md"), "# 第1章 First\n\nOnly persisted chapter.", "utf-8");
  409. await writeFile(
  410. join(chaptersDir, "index.json"),
  411. JSON.stringify([
  412. {
  413. number: 1,
  414. title: "First",
  415. status: "ready-for-review",
  416. wordCount: 42,
  417. createdAt: "2026-03-29T00:00:00.000Z",
  418. updatedAt: "2026-03-29T00:00:00.000Z",
  419. auditIssues: [],
  420. lengthWarnings: [],
  421. },
  422. ], null, 2),
  423. "utf-8",
  424. );
  425. await Promise.all([
  426. writeFile(
  427. join(stateDir, "manifest.json"),
  428. JSON.stringify({
  429. schemaVersion: 2,
  430. language: "zh",
  431. lastAppliedChapter: 4,
  432. projectionVersion: 1,
  433. migrationWarnings: [],
  434. }, null, 2),
  435. "utf-8",
  436. ),
  437. writeFile(
  438. join(stateDir, "current_state.json"),
  439. JSON.stringify({
  440. chapter: 4,
  441. facts: [],
  442. }, null, 2),
  443. "utf-8",
  444. ),
  445. writeFile(join(stateDir, "hooks.json"), JSON.stringify({ hooks: [] }, null, 2), "utf-8"),
  446. writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({ rows: [] }, null, 2), "utf-8"),
  447. ]);
  448. const output = run(["status", bookId]);
  449. expect(output).toContain("Chapters: 1 / 10");
  450. expect(output).not.toContain("Chapters: 4 / 10");
  451. const json = JSON.parse(run(["status", bookId, "--json"]));
  452. expect(json.books[0]?.chapters).toBe(1);
  453. });
  454. });
  455. describe("inkos doctor", () => {
  456. it("checks environment health", () => {
  457. const { stdout } = runStderr(["doctor"]);
  458. expect(stdout).toContain("InkOS Doctor");
  459. expect(stdout).toContain("Node.js >= 20");
  460. expect(stdout).toContain("SQLite memory index");
  461. expect(stdout).toContain("inkos.json");
  462. });
  463. it("repairs missing node runtime pin files for old projects", async () => {
  464. await stat(join(projectDir, "inkos.json")).catch(() => {
  465. run(["init"]);
  466. });
  467. await rm(join(projectDir, ".nvmrc"), { force: true });
  468. await rm(join(projectDir, ".node-version"), { force: true });
  469. const before = runStderr(["doctor"]);
  470. expect(before.stdout).toContain("Node runtime pin files");
  471. expect(before.stdout).toContain(".nvmrc");
  472. expect(before.stdout).toContain(".node-version");
  473. const repaired = runStderr(["doctor", "--repair-node-runtime"]);
  474. expect(repaired.stdout).toContain("Node runtime pin files repaired");
  475. expect(repaired.stdout).toContain(".nvmrc");
  476. expect(repaired.stdout).toContain(".node-version");
  477. await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
  478. await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
  479. });
  480. it("treats localhost OpenAI-compatible endpoints as API-key optional", async () => {
  481. await stat(join(projectDir, "inkos.json")).catch(() => {
  482. run(["init"]);
  483. });
  484. const configPath = join(projectDir, "inkos.json");
  485. const envPath = join(projectDir, ".env");
  486. const originalConfig = await readFile(configPath, "utf-8");
  487. const originalEnv = await readFile(envPath, "utf-8");
  488. try {
  489. const config = JSON.parse(originalConfig);
  490. config.llm.provider = "openai";
  491. config.llm.baseUrl = "http://127.0.0.1:11434/v1";
  492. config.llm.model = "gpt-oss:20b";
  493. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  494. await writeFile(envPath, [
  495. "INKOS_LLM_PROVIDER=openai",
  496. "INKOS_LLM_BASE_URL=http://127.0.0.1:11434/v1",
  497. "INKOS_LLM_MODEL=gpt-oss:20b",
  498. "",
  499. ].join("\n"), "utf-8");
  500. const { stdout } = runStderr(["doctor"], {
  501. env: { INKOS_LLM_API_KEY: "" },
  502. });
  503. expect(stdout).toContain("LLM API Key");
  504. expect(stdout).toContain("Optional for local/self-hosted endpoint");
  505. expect(stdout).toContain("LLM Config");
  506. expect(stdout).not.toContain("No LLM config available");
  507. } finally {
  508. await writeFile(configPath, originalConfig, "utf-8");
  509. await writeFile(envPath, originalEnv, "utf-8");
  510. }
  511. });
  512. it("reports legacy books in the version migration check", async () => {
  513. const bookDir = join(projectDir, "books", "legacy-doctor-hint");
  514. const storyDir = join(bookDir, "story");
  515. await mkdir(join(bookDir, "chapters"), { recursive: true });
  516. await mkdir(storyDir, { recursive: true });
  517. await writeFile(
  518. join(bookDir, "book.json"),
  519. JSON.stringify({
  520. id: "legacy-doctor-hint",
  521. title: "Legacy Doctor Hint",
  522. platform: "other",
  523. genre: "other",
  524. status: "active",
  525. targetChapters: 10,
  526. chapterWordCount: 2200,
  527. createdAt: "2026-03-22T00:00:00.000Z",
  528. updatedAt: "2026-03-22T00:00:00.000Z",
  529. }, null, 2),
  530. "utf-8",
  531. );
  532. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  533. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  534. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  535. const { stdout } = runStderr(["doctor"]);
  536. expect(stdout).toContain("Version Migration");
  537. expect(stdout).toContain("legacy format");
  538. });
  539. });
  540. describe("inkos write", () => {
  541. it("warns before writing when the target book still uses legacy format", async () => {
  542. const bookDir = join(projectDir, "books", "legacy-write-hint");
  543. const storyDir = join(bookDir, "story");
  544. await mkdir(join(bookDir, "chapters"), { recursive: true });
  545. await mkdir(storyDir, { recursive: true });
  546. await writeFile(
  547. join(bookDir, "book.json"),
  548. JSON.stringify({
  549. id: "legacy-write-hint",
  550. title: "Legacy Write Hint",
  551. platform: "other",
  552. genre: "other",
  553. status: "active",
  554. targetChapters: 10,
  555. chapterWordCount: 2200,
  556. createdAt: "2026-03-22T00:00:00.000Z",
  557. updatedAt: "2026-03-22T00:00:00.000Z",
  558. }, null, 2),
  559. "utf-8",
  560. );
  561. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  562. await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
  563. await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
  564. const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], {
  565. env: failingLlmEnv,
  566. });
  567. expect(`${stdout}\n${stderr}`).toContain("legacy format");
  568. });
  569. it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
  570. const state = new StateManager(projectDir);
  571. const bookId = "rewrite-cli";
  572. const bookDir = join(projectDir, "books", bookId);
  573. const storyDir = join(bookDir, "story");
  574. const chaptersDir = join(bookDir, "chapters");
  575. const stateDir = join(storyDir, "state");
  576. await mkdir(chaptersDir, { recursive: true });
  577. await mkdir(stateDir, { recursive: true });
  578. await writeFile(
  579. join(bookDir, "book.json"),
  580. JSON.stringify({
  581. id: bookId,
  582. title: "Rewrite CLI",
  583. platform: "other",
  584. genre: "other",
  585. status: "active",
  586. targetChapters: 10,
  587. chapterWordCount: 2200,
  588. createdAt: "2026-03-22T00:00:00.000Z",
  589. updatedAt: "2026-03-22T00:00:00.000Z",
  590. }, null, 2),
  591. "utf-8",
  592. );
  593. await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
  594. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
  595. await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
  596. await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
  597. await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8");
  598. await writeFile(
  599. join(chaptersDir, "index.json"),
  600. JSON.stringify([
  601. { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  602. { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  603. { number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
  604. ], null, 2),
  605. "utf-8",
  606. );
  607. await state.snapshotState(bookId, 1);
  608. await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
  609. await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
  610. schemaVersion: 2,
  611. language: "en",
  612. lastAppliedChapter: 4,
  613. projectionVersion: 1,
  614. migrationWarnings: [],
  615. }, null, 2), "utf-8");
  616. await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
  617. chapter: 3,
  618. facts: [],
  619. }, null, 2), "utf-8");
  620. const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
  621. env: failingLlmEnv,
  622. });
  623. expect(exitCode).not.toBe(0);
  624. expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
  625. const next = await state.getNextChapterNumber(bookId);
  626. expect(next).toBe(2);
  627. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1");
  628. });
  629. });
  630. describe("inkos analytics", () => {
  631. it("errors when no book exists", () => {
  632. const { exitCode } = runStderr(["analytics"]);
  633. expect(exitCode).not.toBe(0);
  634. });
  635. });
  636. describe("inkos review", () => {
  637. it("preserves the original chapter snapshot when approving review", async () => {
  638. const configPath = join(projectDir, "inkos.json");
  639. const initialized = await stat(configPath).then(() => true).catch(() => false);
  640. if (!initialized) run(["init"]);
  641. const state = new StateManager(projectDir);
  642. const bookId = "review-approve-cli";
  643. const bookDir = join(projectDir, "books", bookId);
  644. const storyDir = join(bookDir, "story");
  645. const chaptersDir = join(bookDir, "chapters");
  646. await mkdir(chaptersDir, { recursive: true });
  647. await mkdir(storyDir, { recursive: true });
  648. await writeFile(
  649. join(bookDir, "book.json"),
  650. JSON.stringify({
  651. id: bookId,
  652. title: "Review Approve CLI",
  653. platform: "other",
  654. genre: "other",
  655. status: "active",
  656. targetChapters: 10,
  657. chapterWordCount: 2200,
  658. createdAt: "2026-03-22T00:00:00.000Z",
  659. updatedAt: "2026-03-22T00:00:00.000Z",
  660. }, null, 2),
  661. "utf-8",
  662. );
  663. await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
  664. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
  665. await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
  666. await writeFile(
  667. join(chaptersDir, "index.json"),
  668. JSON.stringify([
  669. {
  670. number: 1,
  671. title: "Ch1",
  672. status: "ready-for-review",
  673. wordCount: 100,
  674. createdAt: "",
  675. updatedAt: "",
  676. auditIssues: [],
  677. lengthWarnings: [],
  678. },
  679. ], null, 2),
  680. "utf-8",
  681. );
  682. await state.snapshotState(bookId, 1);
  683. await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
  684. await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch3", "utf-8");
  685. const output = run(["review", "approve", bookId, "1"]);
  686. expect(output).toContain("Chapter 1 approved");
  687. await expect(
  688. readFile(join(storyDir, "snapshots", "1", "current_state.md"), "utf-8"),
  689. ).resolves.toBe("State at ch1");
  690. await expect(
  691. readFile(join(storyDir, "snapshots", "1", "pending_hooks.md"), "utf-8"),
  692. ).resolves.toBe("Hooks at ch1");
  693. const index = await state.loadChapterIndex(bookId);
  694. expect(index[0]?.status).toBe("approved");
  695. });
  696. });
  697. describe("inkos plan/compose", () => {
  698. beforeAll(async () => {
  699. const configPath = join(projectDir, "inkos.json");
  700. const initialized = await stat(configPath).then(() => true).catch(() => false);
  701. if (!initialized) run(["init"]);
  702. const bookDir = join(projectDir, "books", "cli-book");
  703. const storyDir = join(bookDir, "story");
  704. await mkdir(join(storyDir, "runtime"), { recursive: true });
  705. await writeFile(
  706. join(bookDir, "book.json"),
  707. JSON.stringify({
  708. id: "cli-book",
  709. title: "CLI Book",
  710. platform: "tomato",
  711. genre: "other",
  712. status: "active",
  713. targetChapters: 20,
  714. chapterWordCount: 3000,
  715. createdAt: "2026-03-22T00:00:00.000Z",
  716. updatedAt: "2026-03-22T00:00:00.000Z",
  717. }, null, 2),
  718. "utf-8",
  719. );
  720. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8").catch(async () => {
  721. await mkdir(join(bookDir, "chapters"), { recursive: true });
  722. await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
  723. });
  724. await Promise.all([
  725. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
  726. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  727. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  728. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  729. writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
  730. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  731. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  732. ]);
  733. });
  734. it("runs plan chapter and returns the generated intent path in JSON mode", async () => {
  735. const output = run(["plan", "chapter", "cli-book", "--json", "--context", "Ignore the guild chase and focus on the mentor conflict."]);
  736. const data = JSON.parse(output);
  737. expect(data.bookId).toBe("cli-book");
  738. expect(data.chapterNumber).toBe(1);
  739. expect(data.intentPath).toContain("story/runtime/chapter-0001.intent.md");
  740. await expect(stat(join(projectDir, "books", "cli-book", data.intentPath))).resolves.toBeTruthy();
  741. });
  742. it("runs compose chapter and returns runtime artifact paths in JSON mode", async () => {
  743. const output = run(["compose", "chapter", "cli-book", "--json"]);
  744. const data = JSON.parse(output);
  745. expect(data.bookId).toBe("cli-book");
  746. expect(data.chapterNumber).toBe(1);
  747. expect(data.contextPath).toContain("story/runtime/chapter-0001.context.json");
  748. expect(data.ruleStackPath).toContain("story/runtime/chapter-0001.rule-stack.yaml");
  749. expect(data.tracePath).toContain("story/runtime/chapter-0001.trace.json");
  750. await expect(stat(join(projectDir, "books", "cli-book", data.contextPath))).resolves.toBeTruthy();
  751. await expect(stat(join(projectDir, "books", "cli-book", data.ruleStackPath))).resolves.toBeTruthy();
  752. await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
  753. });
  754. it("reuses the planned intent when compose runs without a new context", async () => {
  755. const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
  756. run(["plan", "chapter", "cli-book", "--context", plannedGoal]);
  757. const output = run(["compose", "chapter", "cli-book", "--json"]);
  758. const data = JSON.parse(output);
  759. const intentMarkdown = await readFile(join(projectDir, "books", "cli-book", data.intentPath), "utf-8");
  760. expect(data.goal).toBe(plannedGoal);
  761. expect(intentMarkdown).toContain(plannedGoal);
  762. });
  763. });
  764. describe("inkos export", () => {
  765. beforeAll(async () => {
  766. const configPath = join(projectDir, "inkos.json");
  767. const initialized = await stat(configPath).then(() => true).catch(() => false);
  768. if (!initialized) run(["init"]);
  769. const bookDir = join(projectDir, "books", "export-book");
  770. await mkdir(join(bookDir, "chapters"), { recursive: true });
  771. await writeFile(
  772. join(bookDir, "book.json"),
  773. JSON.stringify({
  774. id: "export-book",
  775. title: "Export Book",
  776. platform: "tomato",
  777. genre: "xuanhuan",
  778. status: "active",
  779. targetChapters: 10,
  780. chapterWordCount: 2000,
  781. createdAt: "2026-03-23T00:00:00.000Z",
  782. updatedAt: "2026-03-23T00:00:00.000Z",
  783. }, null, 2),
  784. "utf-8",
  785. );
  786. await writeFile(
  787. join(bookDir, "chapters", "index.json"),
  788. JSON.stringify([
  789. {
  790. number: 1,
  791. title: "Dawn Ledger",
  792. status: "ready-for-review",
  793. wordCount: 1200,
  794. createdAt: "2026-03-23T00:00:00.000Z",
  795. updatedAt: "2026-03-23T00:00:00.000Z",
  796. auditIssues: [],
  797. },
  798. ], null, 2),
  799. "utf-8",
  800. );
  801. await writeFile(
  802. join(bookDir, "chapters", "0001_Dawn_Ledger.md"),
  803. "# 第1章 Dawn Ledger\n\n正文。\n",
  804. "utf-8",
  805. );
  806. });
  807. it("creates missing parent directories for custom output paths", async () => {
  808. const outputPath = join(projectDir, "exports", "nested", "book.md");
  809. const output = run(["export", "export-book", "--format", "md", "--output", outputPath, "--json"]);
  810. const data = JSON.parse(output);
  811. expect(data.outputPath).toBe(outputPath);
  812. await expect(stat(outputPath)).resolves.toBeTruthy();
  813. await expect(readFile(outputPath, "utf-8")).resolves.toContain("# Export Book");
  814. });
  815. });
  816. });