novel-writer-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { readCheckpoint } from "../checkpoint.js";
7
+ async function writeText(absPath, contents) {
8
+ await mkdir(dirname(absPath), { recursive: true });
9
+ await writeFile(absPath, contents, "utf8");
10
+ }
11
+ async function writeJson(absPath, payload) {
12
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
13
+ }
14
+ test("readCheckpoint rejects invalid quickstart_phase string", async () => {
15
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-"));
16
+ await writeJson(join(rootDir, ".checkpoint.json"), {
17
+ last_completed_chapter: 0,
18
+ current_volume: 1,
19
+ orchestrator_state: "QUICK_START",
20
+ pipeline_stage: null,
21
+ inflight_chapter: null,
22
+ quickstart_phase: "banana"
23
+ });
24
+ await assert.rejects(() => readCheckpoint(rootDir), /quickstart_phase must be one of:/);
25
+ });
26
+ test("readCheckpoint rejects non-string quickstart_phase", async () => {
27
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-non-string-"));
28
+ await writeJson(join(rootDir, ".checkpoint.json"), {
29
+ last_completed_chapter: 0,
30
+ current_volume: 1,
31
+ orchestrator_state: "QUICK_START",
32
+ pipeline_stage: null,
33
+ inflight_chapter: null,
34
+ quickstart_phase: 42
35
+ });
36
+ await assert.rejects(() => readCheckpoint(rootDir), /quickstart_phase must be a string/);
37
+ });
38
+ test("readCheckpoint accepts legacy checkpoint without quickstart_phase", async () => {
39
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-legacy-missing-"));
40
+ await writeJson(join(rootDir, ".checkpoint.json"), {
41
+ last_completed_chapter: 0,
42
+ current_volume: 1,
43
+ orchestrator_state: "QUICK_START",
44
+ pipeline_stage: null,
45
+ inflight_chapter: null
46
+ });
47
+ const checkpoint = await readCheckpoint(rootDir);
48
+ assert.equal(checkpoint.quickstart_phase ?? null, null);
49
+ });
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { main } from "../cli.js";
7
+ async function writeText(absPath, contents) {
8
+ await mkdir(dirname(absPath), { recursive: true });
9
+ await writeFile(absPath, contents, "utf8");
10
+ }
11
+ async function writeJson(absPath, payload) {
12
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
13
+ }
14
+ async function runCli(argv) {
15
+ let stdout = "";
16
+ let stderr = "";
17
+ const origOut = process.stdout.write;
18
+ const origErr = process.stderr.write;
19
+ process.stdout.write = (chunk) => {
20
+ stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
21
+ return true;
22
+ };
23
+ process.stderr.write = (chunk) => {
24
+ stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
25
+ return true;
26
+ };
27
+ const prevExitCode = process.exitCode;
28
+ try {
29
+ const code = await main(argv);
30
+ return { code, stdout, stderr };
31
+ }
32
+ finally {
33
+ process.exitCode = prevExitCode;
34
+ process.stdout.write = origOut;
35
+ process.stderr.write = origErr;
36
+ }
37
+ }
38
+ test("novel instructions rejects --novel-ask-file without --answer-path (json mode)", async () => {
39
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-novel-ask-missing-answer-"));
40
+ await writeJson(join(rootDir, ".checkpoint.json"), {
41
+ last_completed_chapter: 0,
42
+ current_volume: 1,
43
+ orchestrator_state: "INIT",
44
+ pipeline_stage: null,
45
+ inflight_chapter: null
46
+ });
47
+ const res = await runCli([
48
+ "--json",
49
+ "--project",
50
+ rootDir,
51
+ "instructions",
52
+ "quickstart:world",
53
+ "--novel-ask-file",
54
+ "staging/novel-ask/question.json"
55
+ ]);
56
+ assert.equal(res.code, 2);
57
+ const payload = JSON.parse(res.stdout.trim());
58
+ assert.equal(payload.ok, false);
59
+ assert.match(payload.error.message, /provide both --novel-ask-file and --answer-path/);
60
+ });
61
+ test("novel instructions rejects --answer-path without --novel-ask-file (json mode)", async () => {
62
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-novel-ask-missing-ask-"));
63
+ await writeJson(join(rootDir, ".checkpoint.json"), {
64
+ last_completed_chapter: 0,
65
+ current_volume: 1,
66
+ orchestrator_state: "INIT",
67
+ pipeline_stage: null,
68
+ inflight_chapter: null
69
+ });
70
+ const res = await runCli([
71
+ "--json",
72
+ "--project",
73
+ rootDir,
74
+ "instructions",
75
+ "quickstart:world",
76
+ "--answer-path",
77
+ "staging/novel-ask/answer.json"
78
+ ]);
79
+ assert.equal(res.code, 2);
80
+ const payload = JSON.parse(res.stdout.trim());
81
+ assert.equal(payload.ok, false);
82
+ assert.match(payload.error.message, /provide both --novel-ask-file and --answer-path/);
83
+ });
@@ -0,0 +1,194 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { main } from "../cli.js";
7
+ import { readCheckpoint } from "../checkpoint.js";
8
+ async function writeText(absPath, contents) {
9
+ await mkdir(dirname(absPath), { recursive: true });
10
+ await writeFile(absPath, contents, "utf8");
11
+ }
12
+ async function writeJson(absPath, payload) {
13
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
14
+ }
15
+ async function runCli(argv) {
16
+ let stdout = "";
17
+ let stderr = "";
18
+ const origOut = process.stdout.write;
19
+ const origErr = process.stderr.write;
20
+ process.stdout.write = (chunk) => {
21
+ stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
22
+ return true;
23
+ };
24
+ process.stderr.write = (chunk) => {
25
+ stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
26
+ return true;
27
+ };
28
+ const prevExitCode = process.exitCode;
29
+ try {
30
+ process.exitCode = undefined;
31
+ const code = await main(argv);
32
+ return { code, stdout, stderr };
33
+ }
34
+ finally {
35
+ process.exitCode = prevExitCode;
36
+ process.stdout.write = origOut;
37
+ process.stderr.write = origErr;
38
+ }
39
+ }
40
+ test("novel repair previews reset-quickstart without --force (json mode)", async () => {
41
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-preview-"));
42
+ await writeJson(join(rootDir, ".checkpoint.json"), {
43
+ last_completed_chapter: 0,
44
+ current_volume: 1,
45
+ orchestrator_state: "QUICK_START",
46
+ pipeline_stage: null,
47
+ inflight_chapter: null,
48
+ quickstart_phase: "characters"
49
+ });
50
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart"]);
51
+ assert.equal(res.code, 0);
52
+ assert.equal(res.stderr, "");
53
+ const payload = JSON.parse(res.stdout.trim());
54
+ assert.equal(payload.ok, true);
55
+ assert.equal(payload.command, "repair");
56
+ assert.equal(payload.data.rootDir, rootDir);
57
+ assert.deepEqual(payload.data.actions, ["reset_quickstart"]);
58
+ assert.equal(payload.data.applied, false);
59
+ assert.equal(payload.data.before_present, true);
60
+ assert.equal(payload.data.after_present, true);
61
+ assert.equal(payload.data.changed, false);
62
+ assert.equal(payload.data.would_change, true);
63
+ assert.equal(payload.data.before, "characters");
64
+ assert.equal(payload.data.after, "characters");
65
+ assert.equal(payload.data.target_after, null);
66
+ const checkpoint = await readCheckpoint(rootDir);
67
+ assert.equal(checkpoint.quickstart_phase, "characters");
68
+ });
69
+ test("novel repair --reset-quickstart --force sets quickstart_phase=null (json mode)", async () => {
70
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-force-"));
71
+ await writeJson(join(rootDir, ".checkpoint.json"), {
72
+ last_completed_chapter: 0,
73
+ current_volume: 1,
74
+ orchestrator_state: "QUICK_START",
75
+ pipeline_stage: null,
76
+ inflight_chapter: null,
77
+ quickstart_phase: "characters"
78
+ });
79
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
80
+ assert.equal(res.code, 0);
81
+ assert.equal(res.stderr, "");
82
+ const payload = JSON.parse(res.stdout.trim());
83
+ assert.equal(payload.ok, true);
84
+ assert.equal(payload.command, "repair");
85
+ assert.equal(payload.data.rootDir, rootDir);
86
+ assert.deepEqual(payload.data.actions, ["reset_quickstart"]);
87
+ assert.equal(payload.data.applied, true);
88
+ assert.equal(payload.data.before_present, true);
89
+ assert.equal(payload.data.after_present, true);
90
+ assert.equal(payload.data.changed, true);
91
+ assert.equal(payload.data.would_change, true);
92
+ assert.equal(payload.data.before, "characters");
93
+ assert.equal(payload.data.after, null);
94
+ assert.equal(payload.data.target_after, null);
95
+ const checkpoint = await readCheckpoint(rootDir);
96
+ assert.equal(checkpoint.quickstart_phase, null);
97
+ const raw = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
98
+ assert.match(raw, /\"quickstart_phase\": null/);
99
+ await assert.rejects(() => stat(join(rootDir, ".novel.lock")), /ENOENT/);
100
+ });
101
+ test("novel repair --reset-quickstart --force normalizes missing quickstart_phase to null (json mode)", async () => {
102
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-missing-"));
103
+ await writeJson(join(rootDir, ".checkpoint.json"), {
104
+ last_completed_chapter: 0,
105
+ current_volume: 1,
106
+ orchestrator_state: "QUICK_START",
107
+ pipeline_stage: null,
108
+ inflight_chapter: null
109
+ });
110
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
111
+ assert.equal(res.code, 0);
112
+ assert.equal(res.stderr, "");
113
+ const payload = JSON.parse(res.stdout.trim());
114
+ assert.equal(payload.ok, true);
115
+ assert.equal(payload.command, "repair");
116
+ assert.equal(payload.data.applied, true);
117
+ assert.equal(payload.data.before_present, false);
118
+ assert.equal(payload.data.after_present, true);
119
+ assert.equal(payload.data.changed, true);
120
+ assert.equal(payload.data.would_change, true);
121
+ assert.equal(payload.data.before, null);
122
+ assert.equal(payload.data.after, null);
123
+ const checkpoint = await readCheckpoint(rootDir);
124
+ assert.equal(checkpoint.quickstart_phase, null);
125
+ });
126
+ test("novel repair --reset-quickstart --force is idempotent when already null (json mode)", async () => {
127
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-noop-"));
128
+ await writeJson(join(rootDir, ".checkpoint.json"), {
129
+ last_completed_chapter: 0,
130
+ current_volume: 1,
131
+ orchestrator_state: "QUICK_START",
132
+ pipeline_stage: null,
133
+ inflight_chapter: null,
134
+ quickstart_phase: null
135
+ });
136
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
137
+ assert.equal(res.code, 0);
138
+ const payload = JSON.parse(res.stdout.trim());
139
+ assert.equal(payload.ok, true);
140
+ assert.equal(payload.data.applied, true);
141
+ assert.equal(payload.data.changed, false);
142
+ assert.equal(payload.data.would_change, false);
143
+ assert.equal(payload.data.before, null);
144
+ assert.equal(payload.data.after, null);
145
+ });
146
+ test("novel repair --reset-quickstart fails gracefully when checkpoint missing (json mode)", async () => {
147
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-no-checkpoint-"));
148
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
149
+ assert.equal(res.code, 2);
150
+ const payload = JSON.parse(res.stdout.trim());
151
+ assert.equal(payload.ok, false);
152
+ assert.equal(payload.command, "repair");
153
+ });
154
+ test("novel repair --reset-quickstart fails gracefully when checkpoint is corrupt JSON (json mode)", async () => {
155
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-corrupt-"));
156
+ await writeText(join(rootDir, ".checkpoint.json"), "{ broken json !!!}");
157
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
158
+ assert.ok(res.code !== 0, `expected non-zero exit code, got ${res.code}`);
159
+ const payload = JSON.parse(res.stdout.trim());
160
+ assert.equal(payload.ok, false);
161
+ assert.equal(payload.command, "repair");
162
+ });
163
+ test("novel repair preview reports after_present=true even when field is missing (json mode)", async () => {
164
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-preview-missing-"));
165
+ await writeJson(join(rootDir, ".checkpoint.json"), {
166
+ last_completed_chapter: 0,
167
+ current_volume: 1,
168
+ orchestrator_state: "QUICK_START",
169
+ pipeline_stage: null,
170
+ inflight_chapter: null
171
+ });
172
+ const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart"]);
173
+ assert.equal(res.code, 0);
174
+ const payload = JSON.parse(res.stdout.trim());
175
+ assert.equal(payload.data.before_present, false);
176
+ assert.equal(payload.data.after_present, true);
177
+ assert.equal(payload.data.would_change, true);
178
+ });
179
+ test("novel repair rejects missing actions (json mode)", async () => {
180
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-missing-action-"));
181
+ await writeJson(join(rootDir, ".checkpoint.json"), {
182
+ last_completed_chapter: 0,
183
+ current_volume: 1,
184
+ orchestrator_state: "INIT",
185
+ pipeline_stage: null,
186
+ inflight_chapter: null
187
+ });
188
+ const res = await runCli(["--json", "--project", rootDir, "repair"]);
189
+ assert.equal(res.code, 2);
190
+ const payload = JSON.parse(res.stdout.trim());
191
+ assert.equal(payload.ok, false);
192
+ assert.equal(payload.command, "repair");
193
+ assert.match(payload.error.message, /no actions specified/i);
194
+ });
@@ -58,26 +58,28 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
58
58
  assert.equal(result.rootDir, rootDir);
59
59
  // Exact created set (non-minimal = checkpoint + 4 templates)
60
60
  assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "style-profile.json", "web-novel-cliche-lint.json"].sort());
61
- // All 9 staging dirs ensured
62
- assert.equal(result.ensuredDirs.length, 9);
61
+ // All staging dirs ensured
62
+ assert.equal(result.ensuredDirs.length, 10);
63
63
  assert.ok(result.ensuredDirs.includes("staging/chapters"));
64
64
  assert.ok(result.ensuredDirs.includes("staging/manifests"));
65
65
  assert.ok(result.ensuredDirs.includes("staging/volumes"));
66
66
  assert.ok(result.ensuredDirs.includes("staging/foreshadowing"));
67
+ assert.ok(result.ensuredDirs.includes("staging/quickstart"));
67
68
  // Verify ALL checkpoint fields
68
69
  const checkpoint = await readCheckpoint(rootDir);
69
70
  assert.equal(checkpoint.last_completed_chapter, 0);
70
71
  assert.equal(checkpoint.current_volume, 1);
71
- assert.equal(checkpoint.pipeline_stage, "committed");
72
+ assert.equal(checkpoint.orchestrator_state, "INIT");
73
+ assert.equal(checkpoint.pipeline_stage, null);
72
74
  assert.equal(checkpoint.volume_pipeline_stage, null);
73
75
  assert.equal(checkpoint.inflight_chapter, null);
74
76
  assert.equal(checkpoint.revision_count, 0);
75
77
  assert.equal(checkpoint.hook_fix_count, 0);
76
78
  assert.equal(checkpoint.title_fix_count, 0);
77
79
  assert.ok(typeof checkpoint.last_checkpoint_time === "string" && checkpoint.last_checkpoint_time.length > 0);
78
- // Integration: next step should be chapter:001:draft
80
+ // Integration: next step should be quickstart:world
79
81
  const next = await computeNextStep(rootDir, checkpoint);
80
- assert.equal(next.step, "chapter:001:draft");
82
+ assert.equal(next.step, "quickstart:world");
81
83
  // All staging dirs exist
82
84
  for (const relDir of [
83
85
  "staging/chapters",
@@ -88,7 +90,8 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
88
90
  "staging/storylines",
89
91
  "staging/volumes",
90
92
  "staging/foreshadowing",
91
- "staging/manifests"
93
+ "staging/manifests",
94
+ "staging/quickstart"
92
95
  ]) {
93
96
  await assertDir(join(rootDir, relDir));
94
97
  }
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { buildInstructionPacket } from "../instructions.js";
7
+ test("buildInstructionPacket rejects NOVEL_ASK gate for review steps", async () => {
8
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-review-novel-ask-"));
9
+ const questionSpec = {
10
+ version: 1,
11
+ topic: "review_gate",
12
+ questions: [
13
+ {
14
+ id: "ok_to_continue",
15
+ header: "Continue?",
16
+ question: "Continue the review pipeline?",
17
+ kind: "single_choice",
18
+ required: true,
19
+ options: [{ label: "yes", description: "Proceed" }]
20
+ }
21
+ ]
22
+ };
23
+ await assert.rejects(async () => buildInstructionPacket({
24
+ rootDir,
25
+ checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "VOL_REVIEW" },
26
+ step: { kind: "review", phase: "collect" },
27
+ embedMode: null,
28
+ writeManifest: false,
29
+ novelAskGate: { novel_ask: questionSpec, answer_path: "staging/novel-ask/review.json" }
30
+ }), /NOVEL_ASK gate is not supported for review steps/);
31
+ });
@@ -62,15 +62,17 @@ test("readCheckpoint rejects inflight_chapter=0", async () => {
62
62
  });
63
63
  await assert.rejects(() => readCheckpoint(rootDir), /inflight_chapter must be an int >= 1/);
64
64
  });
65
- test("computeNextStep throws for INIT placeholder", async () => {
65
+ test("computeNextStep routes INIT to quickstart pipeline", async () => {
66
66
  const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-init-"));
67
- await assert.rejects(() => computeNextStep(rootDir, {
67
+ const next = await computeNextStep(rootDir, {
68
68
  last_completed_chapter: 0,
69
69
  current_volume: 1,
70
70
  orchestrator_state: "INIT",
71
71
  pipeline_stage: null,
72
72
  inflight_chapter: null
73
- }), /Not implemented: orchestrator_state=INIT/);
73
+ });
74
+ assert.equal(next.step, "quickstart:world");
75
+ assert.equal(next.reason, "init:quickstart:world");
74
76
  });
75
77
  test("computeNextStep throws when pipeline_stage=committed but inflight_chapter is set", async () => {
76
78
  const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-committed-inflight-"));
@@ -82,15 +84,17 @@ test("computeNextStep throws when pipeline_stage=committed but inflight_chapter
82
84
  inflight_chapter: 7
83
85
  }), /Checkpoint inconsistent: pipeline_stage=committed but inflight_chapter=7/);
84
86
  });
85
- test("computeNextStep throws for QUICK_START placeholder", async () => {
87
+ test("computeNextStep routes QUICK_START to quickstart pipeline", async () => {
86
88
  const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-quickstart-"));
87
- await assert.rejects(() => computeNextStep(rootDir, {
89
+ const next = await computeNextStep(rootDir, {
88
90
  last_completed_chapter: 0,
89
91
  current_volume: 1,
90
92
  orchestrator_state: "QUICK_START",
91
93
  pipeline_stage: null,
92
94
  inflight_chapter: null
93
- }), /Not implemented: orchestrator_state=QUICK_START/);
95
+ });
96
+ assert.equal(next.step, "quickstart:world");
97
+ assert.equal(next.reason, "quickstart:world");
94
98
  });
95
99
  test("computeNextStep prefixes reason for ERROR_RETRY", async () => {
96
100
  const rootDir = await mkdtemp(join(tmpdir(), "novel-orchestrator-error-retry-"));