hdoc-tools 0.52.0 → 0.52.1

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.
package/hdoc-edit.js ADDED
@@ -0,0 +1,1449 @@
1
+ // `hdoc edit` — the structure/content editor server (Phase 1 stub).
2
+ //
3
+ // This is the write-capable sibling of `hdoc serve`. It reuses the SAME
4
+ // /_books/* content + render pipeline (via hdoc-content-routes.js) so the
5
+ // editor's live preview is byte-identical to published output, and it serves
6
+ // a SEPARATE UI surface from editor/ (React + Vite, built to editor/dist).
7
+ //
8
+ // Key differences from `hdoc serve`:
9
+ // - binds 127.0.0.1 only (it has write access; must not be reachable on the LAN)
10
+ // - serves the editor SPA, not the Vue viewer in ui/
11
+ // - will host write routes (/api/toc, /api/page/:id, /api/move) in later phases
12
+ //
13
+ // This stub mounts (1) the shared preview routes and (2) a placeholder editor
14
+ // UI, proving the server shape before the React toolchain is introduced.
15
+
16
+ (() => {
17
+ const express = require("express");
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+ const crypto = require("node:crypto");
21
+ const os = require("node:os");
22
+ const { execFile } = require("node:child_process");
23
+ const hdoc = require(path.join(__dirname, "hdoc-module.js"));
24
+
25
+ const sha1 = (data) =>
26
+ crypto.createHash("sha1").update(Buffer.from(data)).digest("hex");
27
+ const { create_content_handler } = require(
28
+ path.join(__dirname, "hdoc-content-routes.js"),
29
+ );
30
+ const { TocModel } = require(path.join(__dirname, "hdoc-toc.js"));
31
+ const { dialect_findings } = require(path.join(__dirname, "hdoc-spell.js"));
32
+
33
+ let port = 3000;
34
+
35
+ // editor_path is the directory containing the editor SPA. For the stub this
36
+ // is editor/ (a hand-written placeholder index.html). Once the React app is
37
+ // scaffolded, point this at editor/dist (the Vite build output).
38
+ exports.run = async (editor_path, source_path) => {
39
+ for (let x = 0; x < process.argv.length; x++) {
40
+ // First two arguments are command, and script
41
+ if (x === 0 || x === 1) continue;
42
+
43
+ if (process.argv[x].toLowerCase() === "-port") {
44
+ x++;
45
+ if (x < process.argv.length) {
46
+ port = process.argv[x];
47
+ }
48
+ }
49
+ }
50
+
51
+ console.log("Hornbill HDocBook Editor Server", "\r\n");
52
+ console.log(" Editor UI Path:", editor_path);
53
+ console.log(" Document Path:", source_path, "\r\n");
54
+ console.log(" Server Port:", port);
55
+
56
+ const app = express();
57
+
58
+ // --- Active workspace (book) ---
59
+ // The book context is MUTABLE so the editor can switch which local clone
60
+ // it edits at runtime (the GitHub-Desktop "Open" flow). Every route below
61
+ // references these `let`s by name, so reassigning them in open_workspace()
62
+ // re-points the whole editor at a new book with no per-route changes.
63
+ let hdocbook_project;
64
+ let docId;
65
+ let nav_inline = {};
66
+ let hdocbook_config;
67
+ let content;
68
+ let toc;
69
+ let project_path;
70
+ let watcher = null;
71
+
72
+ // --- live filesystem sync (auto-save friendly) ---
73
+ // Connected SSE clients + a record of files WE just wrote, so the watcher
74
+ // can ignore our own writes and only push genuine external changes.
75
+ const sse_clients = new Set();
76
+ const recent_writes = new Map(); // absPath -> { hash, time }
77
+
78
+ const note_write = (abs, data) => {
79
+ recent_writes.set(abs, { hash: sha1(data), time: Date.now() });
80
+ // prune old entries
81
+ const cutoff = Date.now() - 10000;
82
+ for (const [k, v] of recent_writes) {
83
+ if (v.time < cutoff) recent_writes.delete(k);
84
+ }
85
+ };
86
+
87
+ const broadcast = (evt) => {
88
+ const data = `data: ${JSON.stringify(evt)}\n\n`;
89
+ for (const c of sse_clients) {
90
+ try {
91
+ c.write(data);
92
+ } catch {
93
+ /* client gone */
94
+ }
95
+ }
96
+ };
97
+
98
+ const handle_fs_change = (rel) => {
99
+ const abs = path.join(source_path, rel);
100
+ let exists = false;
101
+ let buf = null;
102
+ try {
103
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
104
+ exists = true;
105
+ buf = fs.readFileSync(abs);
106
+ }
107
+ } catch {
108
+ /* transient */
109
+ }
110
+ // Ignore our own writes (content matches what we just wrote).
111
+ if (exists && buf) {
112
+ const note = recent_writes.get(abs);
113
+ if (note && note.hash === sha1(buf) && Date.now() - note.time < 5000) {
114
+ return;
115
+ }
116
+ }
117
+ // External structure edit → resync the in-memory model.
118
+ if (toc && abs === toc.hdocbook_path) {
119
+ if (exists) {
120
+ try {
121
+ toc.reload();
122
+ } catch (e) {
123
+ console.error("TOC reload failed:", e.message);
124
+ }
125
+ }
126
+ broadcast({ type: "structure" });
127
+ return;
128
+ }
129
+ broadcast({ type: "fs", path: rel, exists });
130
+ };
131
+
132
+ // (Re)start the recursive file watcher for the current book, closing any
133
+ // previous one. fs.watch recursive works on Windows/macOS (Linux: chokidar).
134
+ const watch_debounce = new Map();
135
+ const start_watch = () => {
136
+ if (watcher) {
137
+ try {
138
+ watcher.close();
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ watcher = null;
143
+ }
144
+ watch_debounce.clear();
145
+ const watch_root = path.join(source_path, docId);
146
+ try {
147
+ watcher = fs.watch(watch_root, { recursive: true }, (_event, filename) => {
148
+ if (!filename) return;
149
+ const rel = `${docId}/${String(filename).split(path.sep).join("/")}`;
150
+ clearTimeout(watch_debounce.get(rel));
151
+ watch_debounce.set(
152
+ rel,
153
+ setTimeout(() => {
154
+ watch_debounce.delete(rel);
155
+ handle_fs_change(rel);
156
+ }, 120),
157
+ );
158
+ });
159
+ console.log("Watching for external changes:", watch_root);
160
+ } catch (e) {
161
+ console.error("File watching unavailable:", e.message);
162
+ }
163
+ };
164
+
165
+ // Point the editor at a book (a directory containing hdocbook-project.json).
166
+ // Reassigns the workspace `let`s, rebuilds the content handler + TOC model,
167
+ // and re-starts the watcher. Throws if the path isn't a valid HDocBook.
168
+ // Files are read fresh (not require()) so re-opening picks up disk changes.
169
+ const open_workspace = (sp) => {
170
+ const proj_path = path.join(sp, "hdocbook-project.json");
171
+ if (!fs.existsSync(proj_path)) {
172
+ throw new Error("Not an HDocBook (no hdocbook-project.json)");
173
+ }
174
+ const proj = JSON.parse(fs.readFileSync(proj_path, "utf8"));
175
+ const id = proj.docId;
176
+ if (!id) throw new Error("hdocbook-project.json has no docId");
177
+ const cfg = JSON.parse(
178
+ fs.readFileSync(path.join(sp, id, "hdocbook.json"), "utf8"),
179
+ );
180
+
181
+ // Inline-help nav fragment, surfaced via /_books/library.json.
182
+ let inline = {};
183
+ const inline_path = path.join(sp, id, "_inline");
184
+ if (fs.existsSync(inline_path)) {
185
+ inline = { text: "Inline Help Items", expand: true, inline: true, items: [] };
186
+ for (const file of fs.readdirSync(inline_path)) {
187
+ inline.items.push({
188
+ text: file.replace(path.extname(file), ""),
189
+ link: `${id}/_inline/${file.replace(path.extname(file), "")}`,
190
+ });
191
+ }
192
+ }
193
+
194
+ source_path = sp;
195
+ hdocbook_project = proj;
196
+ docId = id;
197
+ hdocbook_config = cfg;
198
+ nav_inline = inline;
199
+ project_path = proj_path;
200
+ content = create_content_handler({
201
+ source_path,
202
+ docId,
203
+ hdocbook_config,
204
+ hdocbook_project,
205
+ nav_inline,
206
+ });
207
+ toc = new TocModel(source_path, docId);
208
+ toc.on_persist = note_write; // suppress our own hdocbook.json writes
209
+ start_watch();
210
+ console.log("Active workspace:", source_path, `(docId: ${docId})`);
211
+ };
212
+
213
+ // Open the launch path if it's a valid book; otherwise start with NO book
214
+ // open (VS Code-style welcome — the user opens a book from the editor's
215
+ // Repositories panel). The server stays up either way.
216
+ try {
217
+ open_workspace(source_path);
218
+ } catch {
219
+ console.log(
220
+ "No HDocBook at the launch path — starting with no book open.",
221
+ );
222
+ }
223
+
224
+ // Book-dependent routes 409 cleanly when no book is open, rather than
225
+ // throwing on a null TOC / content handler.
226
+ const BOOK_REQUIRED =
227
+ /^\/(?:_books|api\/(?:toc|page|pagefile|preview|files|pages|upload|spellcheck|dictionary))(?:\/|$)/;
228
+ app.use((req, res, next) => {
229
+ if (!toc && BOOK_REQUIRED.test(req.path)) {
230
+ return res.status(409).json({ error: "No book open" });
231
+ }
232
+ next();
233
+ });
234
+
235
+ // (1) Shared preview pipeline — identical to `hdoc serve`. Delegated to the
236
+ // CURRENT workspace's content handler so it follows workspace switches.
237
+ app.get("/_books/library.json", (req, res) =>
238
+ content.handle_library_request(req, res),
239
+ );
240
+ app.get("/_books/*splat", (req, res) => content.handle_books_request(req, res));
241
+
242
+ // Serve the published viewer's theme assets (theme-default CSS + highlight.js)
243
+ // under /_ui so the editor's live preview can render with the EXACT same
244
+ // styling as `hdoc serve`. Not book-bound. Same files the production viewer
245
+ // (ui/index.html) loads — no copies, so the preview can never drift.
246
+ app.use("/_ui", express.static(path.join(__dirname, "ui")));
247
+
248
+ // Parse JSON bodies for the /api/* write routes (markdown can be large).
249
+ app.use(express.json({ limit: "5mb" }));
250
+
251
+ // Server-Sent Events stream of filesystem changes.
252
+ app.get("/api/events", (req, res) => {
253
+ res.writeHead(200, {
254
+ "Content-Type": "text/event-stream",
255
+ "Cache-Control": "no-cache",
256
+ Connection: "keep-alive",
257
+ });
258
+ res.write(": connected\n\n");
259
+ sse_clients.add(res);
260
+ const hb = setInterval(() => {
261
+ try {
262
+ res.write(": hb\n\n");
263
+ } catch {
264
+ /* ignore */
265
+ }
266
+ }, 25000);
267
+ req.on("close", () => {
268
+ clearInterval(hb);
269
+ sse_clients.delete(res);
270
+ });
271
+ });
272
+
273
+ // Current active workspace (book) — { open:false } when none is open.
274
+ app.get("/api/workspace", (req, res) => {
275
+ if (!toc) return res.json({ open: false });
276
+ res.json({
277
+ open: true,
278
+ path: source_path,
279
+ docId,
280
+ title: (hdocbook_config && hdocbook_config.title) || docId,
281
+ });
282
+ });
283
+
284
+ // Open a local book (the GitHub-Desktop "Open" flow). Re-points the whole
285
+ // server and tells connected clients to reload.
286
+ app.post("/api/workspace/open", (req, res) => {
287
+ const body = req.body || {};
288
+ const sp = String(body.path || "");
289
+ if (!sp) return res.status(400).json({ error: "Missing path" });
290
+ try {
291
+ open_workspace(sp);
292
+ } catch (e) {
293
+ return res.status(400).json({ error: String((e && e.message) || e) });
294
+ }
295
+ broadcast({ type: "workspace" });
296
+ res.json({
297
+ ok: true,
298
+ open: true,
299
+ path: source_path,
300
+ docId,
301
+ title: (hdocbook_config && hdocbook_config.title) || docId,
302
+ });
303
+ });
304
+
305
+ // Close the current book → back to the no-book welcome state.
306
+ app.post("/api/workspace/close", (req, res) => {
307
+ if (watcher) {
308
+ try {
309
+ watcher.close();
310
+ } catch {
311
+ /* ignore */
312
+ }
313
+ watcher = null;
314
+ }
315
+ source_path = undefined;
316
+ hdocbook_project = undefined;
317
+ docId = undefined;
318
+ hdocbook_config = undefined;
319
+ content = undefined;
320
+ toc = undefined;
321
+ project_path = undefined;
322
+ nav_inline = {};
323
+ console.log("Closed workspace (no book open).");
324
+ broadcast({ type: "workspace" });
325
+ res.json({ open: false });
326
+ });
327
+
328
+ app.get("/api/toc", (req, res) => {
329
+ try {
330
+ res.json(toc.to_dto());
331
+ } catch (e) {
332
+ console.error("Failed to build TOC:", e);
333
+ res.status(500).json({ error: String((e && e.message) || e) });
334
+ }
335
+ });
336
+
337
+ // Load a page's raw markdown source (for editing — NOT rendered/expanded).
338
+ app.get("/api/page/:id", (req, res) => {
339
+ const resolved = toc.resolve_file(req.params.id);
340
+ if (!resolved) return res.status(404).json({ error: "Unknown node id" });
341
+ if (!resolved.abs) {
342
+ return res
343
+ .status(400)
344
+ .json({ error: "Node is not an editable page (no link)" });
345
+ }
346
+ if (!resolved.exists) {
347
+ return res
348
+ .status(404)
349
+ .json({ error: "Page file not found", file: resolved.file });
350
+ }
351
+ try {
352
+ const content_txt = fs.readFileSync(resolved.abs, "utf8");
353
+ res.json({
354
+ id: resolved.node.id,
355
+ link: resolved.link,
356
+ file: resolved.file,
357
+ content: content_txt,
358
+ });
359
+ } catch (e) {
360
+ res.status(500).json({ error: String((e && e.message) || e) });
361
+ }
362
+ });
363
+
364
+ // Render an editor buffer to HTML via the SAME pipeline as published output
365
+ // (preview == published). Anchored at the node's file so includes resolve.
366
+ app.post("/api/preview", async (req, res) => {
367
+ const body = req.body || {};
368
+ if (typeof body.content !== "string") {
369
+ return res.status(400).json({ error: "Missing content" });
370
+ }
371
+ let file_path = path.join(source_path, docId, "_preview.md");
372
+ if (typeof body.file === "string" && body.file) {
373
+ try {
374
+ file_path = toc._safe_rel(body.file, { dir: false }).abs;
375
+ } catch {
376
+ /* fall back to default anchor */
377
+ }
378
+ } else if (body.id) {
379
+ const resolved = toc.resolve_file(body.id);
380
+ if (resolved && resolved.abs) file_path = resolved.abs;
381
+ }
382
+ try {
383
+ const result = await content.render_markdown(file_path, body.content);
384
+ res.json(result);
385
+ } catch (e) {
386
+ console.error("Preview render failed:", e);
387
+ res.status(500).json({ error: String((e && e.message) || e) });
388
+ }
389
+ });
390
+
391
+ // Save a page's markdown source back to disk. The path is resolved from the
392
+ // trusted TOC model (by id), never from a client-supplied path.
393
+ app.put("/api/page/:id", (req, res) => {
394
+ const resolved = toc.resolve_file(req.params.id);
395
+ if (!resolved) return res.status(404).json({ error: "Unknown node id" });
396
+ if (!resolved.abs) {
397
+ return res
398
+ .status(400)
399
+ .json({ error: "Node is not an editable page (no link)" });
400
+ }
401
+ const body = req.body || {};
402
+ if (typeof body.content !== "string") {
403
+ return res.status(400).json({ error: "Missing content" });
404
+ }
405
+ try {
406
+ fs.mkdirSync(path.dirname(resolved.abs), { recursive: true });
407
+ fs.writeFileSync(resolved.abs, body.content, "utf8");
408
+ res.json({
409
+ ok: true,
410
+ file: resolved.file,
411
+ bytes: Buffer.byteLength(body.content, "utf8"),
412
+ });
413
+ } catch (e) {
414
+ res.status(500).json({ error: String((e && e.message) || e) });
415
+ }
416
+ });
417
+
418
+ // Structural edits (Phase 1b: nav-only). Each applies the change, persists
419
+ // hdocbook.json (stamping opaque ids on first save), and returns the fresh
420
+ // tree so the client can re-render with recomputed 1.2.3 numbering.
421
+ app.post("/api/toc/move", (req, res) => {
422
+ const body = req.body || {};
423
+ if (!body.id) return res.status(400).json({ error: "Missing id" });
424
+ try {
425
+ toc.move(body.id, body.parentId ?? null, body.index);
426
+ res.json(toc.to_dto());
427
+ } catch (e) {
428
+ res.status(400).json({ error: String((e && e.message) || e) });
429
+ }
430
+ });
431
+
432
+ app.post("/api/toc/rename", (req, res) => {
433
+ const body = req.body || {};
434
+ if (!body.id) return res.status(400).json({ error: "Missing id" });
435
+ if (typeof body.text !== "string") {
436
+ return res.status(400).json({ error: "Missing text" });
437
+ }
438
+ try {
439
+ toc.rename(body.id, body.text);
440
+ res.json(toc.to_dto());
441
+ } catch (e) {
442
+ res.status(400).json({ error: String((e && e.message) || e) });
443
+ }
444
+ });
445
+
446
+ app.post("/api/toc/draft", (req, res) => {
447
+ const body = req.body || {};
448
+ if (!body.id) return res.status(400).json({ error: "Missing id" });
449
+ try {
450
+ toc.set_draft(body.id, !!body.draft);
451
+ res.json(toc.to_dto());
452
+ } catch (e) {
453
+ res.status(400).json({ error: String((e && e.message) || e) });
454
+ }
455
+ });
456
+
457
+ // Create a nav leaf linking to an existing page.
458
+ app.post("/api/toc/create", (req, res) => {
459
+ const body = req.body || {};
460
+ if (typeof body.link !== "string" || !body.link) {
461
+ return res.status(400).json({ error: "Missing link" });
462
+ }
463
+ try {
464
+ toc.create_leaf(body.parentId ?? null, body.index, body.text ?? "", body.link);
465
+ res.json(toc.to_dto());
466
+ } catch (e) {
467
+ res.status(400).json({ error: String((e && e.message) || e) });
468
+ }
469
+ });
470
+
471
+ // Create an empty nav section (container branch).
472
+ app.post("/api/toc/add-section", (req, res) => {
473
+ const body = req.body || {};
474
+ try {
475
+ toc.add_section(body.parentId ?? null, body.index, body.text ?? "");
476
+ res.json(toc.to_dto());
477
+ } catch (e) {
478
+ res.status(400).json({ error: String((e && e.message) || e) });
479
+ }
480
+ });
481
+
482
+ // Remove a nav node (unlink). With { deleteFile: true } also delete its
483
+ // backing page file. Returns { tree, fileDeleted }.
484
+ app.delete("/api/toc/:id", (req, res) => {
485
+ const body = req.body || {};
486
+ try {
487
+ const node = toc.remove_node(req.params.id);
488
+ let fileDeleted = false;
489
+ if (body.deleteFile && typeof node.link === "string" && node.link) {
490
+ const r = toc._resolve_file(node.link);
491
+ if (r.file && r.fileExists) {
492
+ toc.delete_file(r.file);
493
+ fileDeleted = true;
494
+ }
495
+ }
496
+ res.json({ tree: toc.to_dto(), fileDeleted });
497
+ } catch (e) {
498
+ res.status(400).json({ error: String((e && e.message) || e) });
499
+ }
500
+ });
501
+
502
+ // --- pages on disk (decoupled from nav; "_" folders excluded) ---
503
+
504
+ app.get("/api/files", (req, res) => {
505
+ try {
506
+ res.json(toc.list_files());
507
+ } catch (e) {
508
+ res.status(500).json({ error: String((e && e.message) || e) });
509
+ }
510
+ });
511
+
512
+ // Upload an image/asset (raw bytes) to a path inside the book.
513
+ app.put(
514
+ "/api/upload",
515
+ express.raw({ type: () => true, limit: "25mb" }),
516
+ (req, res) => {
517
+ try {
518
+ const { abs, rel } = toc.resolve_upload(String(req.query.path || ""));
519
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
520
+ return res.status(400).json({ error: "Empty upload body" });
521
+ }
522
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
523
+ fs.writeFileSync(abs, req.body);
524
+ note_write(abs, req.body);
525
+ res.json({ ok: true, file: rel, bytes: req.body.length });
526
+ } catch (e) {
527
+ res.status(400).json({ error: String((e && e.message) || e) });
528
+ }
529
+ },
530
+ );
531
+
532
+ // --- spell check (dialect) + shared custom dictionary ---
533
+ // The custom dictionary lives in hdocbook-project.json under
534
+ // validation.spellcheckDictionary, so `hdoc validate` honours it too.
535
+ // (project_path is a workspace `let` set by open_workspace so it follows
536
+ // workspace switches.)
537
+
538
+ const read_dictionary = () => {
539
+ try {
540
+ const proj = JSON.parse(fs.readFileSync(project_path, "utf8"));
541
+ const list = proj.validation && proj.validation.spellcheckDictionary;
542
+ return Array.isArray(list) ? list : [];
543
+ } catch {
544
+ return [];
545
+ }
546
+ };
547
+
548
+ const add_dictionary_word = (word) => {
549
+ const raw = fs.readFileSync(project_path, "utf8");
550
+ const m = raw.match(/\n(\t+| +)\S/);
551
+ const indent = m ? (m[1].includes("\t") ? "\t" : m[1].length) : 2;
552
+ const proj = JSON.parse(raw);
553
+ if (!proj.validation) proj.validation = {};
554
+ if (!Array.isArray(proj.validation.spellcheckDictionary)) {
555
+ proj.validation.spellcheckDictionary = [];
556
+ }
557
+ const w = String(word).trim();
558
+ const list = proj.validation.spellcheckDictionary;
559
+ if (w && !list.some((x) => x.toLowerCase() === w.toLowerCase())) {
560
+ list.push(w);
561
+ list.sort((a, b) => a.localeCompare(b));
562
+ }
563
+ fs.writeFileSync(
564
+ project_path,
565
+ JSON.stringify(proj, null, indent) + (raw.endsWith("\n") ? "\n" : ""),
566
+ "utf8",
567
+ );
568
+ return list;
569
+ };
570
+
571
+ app.post("/api/spellcheck", (req, res) => {
572
+ const body = req.body || {};
573
+ if (typeof body.content !== "string") {
574
+ return res.status(400).json({ error: "Missing content" });
575
+ }
576
+ try {
577
+ const dialect = dialect_findings(body.content, read_dictionary());
578
+ res.json({ dialect });
579
+ } catch (e) {
580
+ res.status(500).json({ error: String((e && e.message) || e) });
581
+ }
582
+ });
583
+
584
+ app.get("/api/dictionary", (req, res) => {
585
+ res.json({ words: read_dictionary() });
586
+ });
587
+
588
+ app.post("/api/dictionary", (req, res) => {
589
+ const body = req.body || {};
590
+ if (typeof body.word !== "string" || !body.word.trim()) {
591
+ return res.status(400).json({ error: "Missing word" });
592
+ }
593
+ try {
594
+ res.json({ words: add_dictionary_word(body.word) });
595
+ } catch (e) {
596
+ res.status(400).json({ error: String((e && e.message) || e) });
597
+ }
598
+ });
599
+
600
+ // --- AI writing assistance (Anthropic Messages API via native fetch) ---
601
+ //
602
+ // Calls Claude server-side so the API key never reaches the browser, and
603
+ // streams the edited Markdown straight back to the editor as it is
604
+ // generated. Deliberately uses global fetch (Node 18+) rather than the
605
+ // Anthropic SDK, so this adds ZERO npm dependencies and never touches the
606
+ // fragile root npm-shrinkwrap.json.
607
+ //
608
+ // Model + limits are configurable in hdocbook-project.json under "ai":
609
+ // { "ai": { "model": "claude-sonnet-4-6", "maxTokens": 4096 } }
610
+ // Sonnet 4.6 is the default — strong on prose at a good price; set it to
611
+ // claude-haiku-4-5 for faster/cheaper edits or claude-opus-4-8 for the
612
+ // highest quality. The API key is read from the ANTHROPIC_API_KEY env var.
613
+ const ai_cfg =
614
+ hdocbook_project && hdocbook_project.ai && typeof hdocbook_project.ai === "object"
615
+ ? hdocbook_project.ai
616
+ : {};
617
+ const AI_MODEL =
618
+ typeof ai_cfg.model === "string" && ai_cfg.model
619
+ ? ai_cfg.model
620
+ : "claude-sonnet-4-6";
621
+ const AI_MAX_TOKENS = Number.isFinite(ai_cfg.maxTokens)
622
+ ? ai_cfg.maxTokens
623
+ : 4096;
624
+ const AI_ENDPOINT = "https://api.anthropic.com/v1/messages";
625
+
626
+ // Stable system prompt — cached server-side (cache_control) so repeated
627
+ // edits in a session reuse it. House style mirrors the spell-checker's
628
+ // US/International English convention.
629
+ const AI_SYSTEM = [
630
+ "You are a writing assistant embedded in the Hornbill HDocBook editor.",
631
+ "You help authors edit technical documentation written in Markdown.",
632
+ "",
633
+ "House style:",
634
+ "- Use US / International English spelling and vocabulary.",
635
+ "- Be clear, concise and direct; prefer active voice and present tense.",
636
+ "- Preserve the author's meaning and intent. Never invent facts, features or details.",
637
+ "",
638
+ "Markdown and HDocBook rules:",
639
+ "- Preserve all Markdown structure: headings, lists, tables, links, images, code fences and inline `code`.",
640
+ "- Never change the contents of fenced code blocks or inline code spans.",
641
+ "- Preserve HDocBook markup verbatim, including [[INCLUDE ...]], [[File:...]] and wiki-style directives.",
642
+ "- Do not change link or image URLs.",
643
+ "",
644
+ "Output rules:",
645
+ "- Return only the edited Markdown for the supplied text — no preamble, explanation, commentary or surrounding code fence.",
646
+ ].join("\n");
647
+
648
+ const AI_ACTIONS = {
649
+ rewrite:
650
+ "Rewrite the supplied text to improve clarity, flow and readability while preserving its meaning and all Markdown structure.",
651
+ grammar:
652
+ "Correct only grammar, spelling and punctuation in the supplied text. Make the minimum changes necessary; do not otherwise rephrase or restructure it.",
653
+ tighten:
654
+ "Make the supplied text more concise and direct without losing information. Remove redundancy and wordiness.",
655
+ };
656
+
657
+ app.post("/api/assist", async (req, res) => {
658
+ const apiKey = process.env.ANTHROPIC_API_KEY;
659
+ if (!apiKey) {
660
+ return res.status(503).json({
661
+ error:
662
+ "ANTHROPIC_API_KEY is not set. Set it in the environment running `hdoc edit` to enable AI writing assistance.",
663
+ });
664
+ }
665
+ if (typeof fetch !== "function") {
666
+ return res.status(500).json({
667
+ error: "Global fetch is unavailable — Node 18+ is required for AI assistance.",
668
+ });
669
+ }
670
+
671
+ const body = req.body || {};
672
+ const text = typeof body.text === "string" ? body.text : "";
673
+ const action = typeof body.action === "string" ? body.action : "rewrite";
674
+ const instruction =
675
+ typeof body.instruction === "string" ? body.instruction.trim() : "";
676
+
677
+ if (!text.trim()) {
678
+ return res.status(400).json({ error: "No text to edit" });
679
+ }
680
+
681
+ const task =
682
+ action === "custom"
683
+ ? instruction || "Improve the supplied text."
684
+ : AI_ACTIONS[action] || AI_ACTIONS.rewrite;
685
+
686
+ let system = AI_SYSTEM;
687
+ const dict = read_dictionary();
688
+ if (dict.length) {
689
+ system +=
690
+ "\n\nThe following are correctly-spelled domain terms; keep them as-is: " +
691
+ dict.join(", ") +
692
+ ".";
693
+ }
694
+
695
+ const user_text = `Task: ${task}\n\nHere is the Markdown to edit:\n\n<text>\n${text}\n</text>\n\nReturn only the edited Markdown.`;
696
+
697
+ let upstream;
698
+ try {
699
+ upstream = await fetch(AI_ENDPOINT, {
700
+ method: "POST",
701
+ headers: {
702
+ "content-type": "application/json",
703
+ "x-api-key": apiKey,
704
+ "anthropic-version": "2023-06-01",
705
+ },
706
+ body: JSON.stringify({
707
+ model: AI_MODEL,
708
+ max_tokens: AI_MAX_TOKENS,
709
+ stream: true,
710
+ system: [
711
+ {
712
+ type: "text",
713
+ text: system,
714
+ cache_control: { type: "ephemeral" },
715
+ },
716
+ ],
717
+ messages: [{ role: "user", content: user_text }],
718
+ }),
719
+ });
720
+ } catch (e) {
721
+ return res.status(502).json({
722
+ error: "Failed to reach the Anthropic API: " + String((e && e.message) || e),
723
+ });
724
+ }
725
+
726
+ if (!upstream.ok || !upstream.body) {
727
+ let detail = "Anthropic API error (HTTP " + upstream.status + ")";
728
+ try {
729
+ const err = await upstream.json();
730
+ if (err && err.error && err.error.message) detail = err.error.message;
731
+ } catch {
732
+ /* non-JSON error body */
733
+ }
734
+ return res
735
+ .status(upstream.status === 401 ? 401 : 502)
736
+ .json({ error: detail });
737
+ }
738
+
739
+ // Stream text deltas straight to the editor as plain text.
740
+ res.writeHead(200, {
741
+ "Content-Type": "text/plain; charset=utf-8",
742
+ "Cache-Control": "no-cache",
743
+ });
744
+
745
+ const reader = upstream.body.getReader();
746
+ const decoder = new TextDecoder();
747
+ let buf = "";
748
+ try {
749
+ for (;;) {
750
+ const { done, value } = await reader.read();
751
+ if (done) break;
752
+ buf += decoder.decode(value, { stream: true });
753
+ // Anthropic streams SSE: one JSON object per `data:` line.
754
+ let nl;
755
+ while ((nl = buf.indexOf("\n")) >= 0) {
756
+ const line = buf.slice(0, nl).trim();
757
+ buf = buf.slice(nl + 1);
758
+ if (!line.startsWith("data:")) continue;
759
+ const payload = line.slice(5).trim();
760
+ if (!payload) continue;
761
+ try {
762
+ const evt = JSON.parse(payload);
763
+ if (
764
+ evt.type === "content_block_delta" &&
765
+ evt.delta &&
766
+ evt.delta.type === "text_delta"
767
+ ) {
768
+ res.write(evt.delta.text);
769
+ }
770
+ } catch {
771
+ /* partial frame split across chunks — ignore */
772
+ }
773
+ }
774
+ }
775
+ } catch (e) {
776
+ console.error("AI assist stream error:", (e && e.message) || e);
777
+ }
778
+ res.end();
779
+ });
780
+
781
+ // --- Git connectivity playground (POC) ---
782
+ //
783
+ // Proves out the "GitHub as a live backing store" architecture: read repo
784
+ // metadata, browse/read files, and write a commit — all via the GitHub
785
+ // REST API over native fetch (no git binary, no local clone, zero new npm
786
+ // deps). Token comes from the request body (loopback only) or the
787
+ // GITHUB_TOKEN env var. This is a sandbox to experiment with connectivity
788
+ // before committing to an architecture; it is NOT wired into the editor's
789
+ // save path.
790
+ const GITHUB_API = "https://api.github.com";
791
+
792
+ async function github(token, method, url_path, body) {
793
+ const headers = {
794
+ accept: "application/vnd.github+json",
795
+ "x-github-api-version": "2022-11-28",
796
+ "user-agent": "hdoc-edit-git-playground",
797
+ };
798
+ if (token) headers.authorization = "Bearer " + token;
799
+ if (body) headers["content-type"] = "application/json";
800
+ const r = await fetch(GITHUB_API + url_path, {
801
+ method,
802
+ headers,
803
+ body: body ? JSON.stringify(body) : undefined,
804
+ });
805
+ const text = await r.text();
806
+ let json = null;
807
+ try {
808
+ json = text ? JSON.parse(text) : null;
809
+ } catch {
810
+ json = { raw: text };
811
+ }
812
+ return {
813
+ ok: r.ok,
814
+ status: r.status,
815
+ json,
816
+ rate: {
817
+ remaining: r.headers.get("x-ratelimit-remaining"),
818
+ limit: r.headers.get("x-ratelimit-limit"),
819
+ },
820
+ };
821
+ }
822
+
823
+ const split_repo = (full) => {
824
+ const parts = String(full || "").trim().split("/");
825
+ return { owner: parts[0] || "", repo: parts.slice(1).join("/") || "" };
826
+ };
827
+ const git_token = (body) =>
828
+ (body.token && String(body.token).trim()) ||
829
+ process.env.GITHUB_TOKEN ||
830
+ "";
831
+ const git_guard = (req, res) => {
832
+ if (typeof fetch !== "function") {
833
+ res.status(500).json({ error: "Global fetch unavailable — Node 18+ required." });
834
+ return null;
835
+ }
836
+ const token = git_token(req.body || {});
837
+ if (!token) {
838
+ res.status(400).json({
839
+ error: "No GitHub token. Paste a personal access token, or set GITHUB_TOKEN in the server environment.",
840
+ });
841
+ return null;
842
+ }
843
+ return token;
844
+ };
845
+
846
+ // Auth check + optional repo metadata + rate-limit headroom.
847
+ app.post("/api/git/info", async (req, res) => {
848
+ const token = git_guard(req, res);
849
+ if (!token) return;
850
+ const b = req.body || {};
851
+ try {
852
+ const user = await github(token, "GET", "/user");
853
+ if (!user.ok) {
854
+ return res.status(user.status).json({
855
+ error: (user.json && user.json.message) || "GitHub auth failed",
856
+ });
857
+ }
858
+ const out = {
859
+ user: { login: user.json.login, name: user.json.name },
860
+ rate: user.rate,
861
+ };
862
+ const { owner, repo } = split_repo(b.repo);
863
+ if (owner && repo) {
864
+ const r = await github(
865
+ token,
866
+ "GET",
867
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
868
+ );
869
+ if (r.ok) {
870
+ out.repo = {
871
+ full_name: r.json.full_name,
872
+ private: r.json.private,
873
+ default_branch: r.json.default_branch,
874
+ permissions: r.json.permissions,
875
+ };
876
+ } else {
877
+ out.repoError =
878
+ (r.json && r.json.message) || "HTTP " + r.status;
879
+ }
880
+ }
881
+ res.json(out);
882
+ } catch (e) {
883
+ res.status(502).json({ error: String((e && e.message) || e) });
884
+ }
885
+ });
886
+
887
+ // Browse a directory or read a file (the "live backing store" read path).
888
+ app.post("/api/git/contents", async (req, res) => {
889
+ const token = git_guard(req, res);
890
+ if (!token) return;
891
+ const b = req.body || {};
892
+ const { owner, repo } = split_repo(b.repo);
893
+ if (!owner || !repo) {
894
+ return res.status(400).json({ error: "Repository must be in owner/name form" });
895
+ }
896
+ const p = String(b.path || "").replace(/^\/+/, "");
897
+ const ref = b.branch ? `?ref=${encodeURIComponent(b.branch)}` : "";
898
+ try {
899
+ const r = await github(
900
+ token,
901
+ "GET",
902
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${p}${ref}`,
903
+ );
904
+ if (!r.ok) {
905
+ return res
906
+ .status(r.status)
907
+ .json({ error: (r.json && r.json.message) || "HTTP " + r.status });
908
+ }
909
+ if (Array.isArray(r.json)) {
910
+ res.json({
911
+ type: "dir",
912
+ path: p,
913
+ entries: r.json
914
+ .map((e) => ({ name: e.name, path: e.path, kind: e.type, size: e.size }))
915
+ .sort((a, c) =>
916
+ a.kind === c.kind
917
+ ? a.name.localeCompare(c.name)
918
+ : a.kind === "dir"
919
+ ? -1
920
+ : 1,
921
+ ),
922
+ rate: r.rate,
923
+ });
924
+ } else {
925
+ const f = r.json;
926
+ const content =
927
+ f.encoding === "base64" && f.content
928
+ ? Buffer.from(f.content, "base64").toString("utf8")
929
+ : null;
930
+ res.json({
931
+ type: "file",
932
+ path: f.path,
933
+ sha: f.sha,
934
+ size: f.size,
935
+ content,
936
+ rate: r.rate,
937
+ });
938
+ }
939
+ } catch (e) {
940
+ res.status(502).json({ error: String((e && e.message) || e) });
941
+ }
942
+ });
943
+
944
+ // Create/update a file = one commit (the "live backing store" write path).
945
+ app.post("/api/git/commit", async (req, res) => {
946
+ const token = git_guard(req, res);
947
+ if (!token) return;
948
+ const b = req.body || {};
949
+ const { owner, repo } = split_repo(b.repo);
950
+ if (!owner || !repo) {
951
+ return res.status(400).json({ error: "Repository must be in owner/name form" });
952
+ }
953
+ const p = String(b.path || "").replace(/^\/+/, "");
954
+ if (!p) return res.status(400).json({ error: "Missing file path" });
955
+ const branch = b.branch ? String(b.branch) : undefined;
956
+ const message = b.message || "hdoc playground commit";
957
+ const content_b64 = Buffer.from(String(b.content || ""), "utf8").toString("base64");
958
+ try {
959
+ // Optimistic concurrency: include the current blob sha if the file
960
+ // exists (this is the GitHub equivalent of the editor's etag check).
961
+ let sha;
962
+ const existing = await github(
963
+ token,
964
+ "GET",
965
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${p}${branch ? `?ref=${encodeURIComponent(branch)}` : ""}`,
966
+ );
967
+ if (existing.ok && !Array.isArray(existing.json)) sha = existing.json.sha;
968
+
969
+ const put_body = { message, content: content_b64 };
970
+ if (sha) put_body.sha = sha;
971
+ if (branch) put_body.branch = branch;
972
+ const r = await github(
973
+ token,
974
+ "PUT",
975
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${p}`,
976
+ put_body,
977
+ );
978
+ if (!r.ok) {
979
+ return res
980
+ .status(r.status)
981
+ .json({ error: (r.json && r.json.message) || "HTTP " + r.status });
982
+ }
983
+ res.json({
984
+ updated: !!sha,
985
+ commit: {
986
+ sha: r.json.commit && r.json.commit.sha,
987
+ html_url: r.json.commit && r.json.commit.html_url,
988
+ },
989
+ path: r.json.content && r.json.content.path,
990
+ rate: r.rate,
991
+ });
992
+ } catch (e) {
993
+ res.status(502).json({ error: String((e && e.message) || e) });
994
+ }
995
+ });
996
+
997
+ // --- GitHub login (Device Flow) + repo browsing (POC) ---
998
+ //
999
+ // Device Flow is the right sign-in for a desktop app: no client secret,
1000
+ // no redirect server — the user authorizes a short code on github.com and
1001
+ // we poll for the token. github.com/login/* has no CORS, so it's proxied
1002
+ // here. Needs a GitHub OAuth App (Device Flow enabled) whose client_id is
1003
+ // set via GITHUB_CLIENT_ID (or passed in the request). PAT login still
1004
+ // works without any of this.
1005
+ const GITHUB_OAUTH = "https://github.com";
1006
+
1007
+ // The OAuth App client_id is NOT a secret (Device Flow uses no client
1008
+ // secret), so it's safe to ship in source — exactly how GitHub Desktop
1009
+ // embeds its own. This is the "Hornbill HDocBook Editor" OAuth App under
1010
+ // the Hornbill-Docs org. Override with the GITHUB_CLIENT_ID env var when
1011
+ // testing against a different app.
1012
+ const DEFAULT_GITHUB_CLIENT_ID = "Ov23liYILIXBsc9HoiNm";
1013
+
1014
+ const device_client_id = (b) =>
1015
+ (b.client_id && String(b.client_id).trim()) ||
1016
+ process.env.GITHUB_CLIENT_ID ||
1017
+ DEFAULT_GITHUB_CLIENT_ID;
1018
+
1019
+ const map_repo = (r) => ({
1020
+ full_name: r.full_name,
1021
+ private: r.private,
1022
+ description: r.description,
1023
+ default_branch: r.default_branch,
1024
+ updated_at: r.updated_at,
1025
+ pushed_at: r.pushed_at,
1026
+ html_url: r.html_url,
1027
+ permissions: r.permissions,
1028
+ });
1029
+
1030
+ // Step 1: request a device + user code.
1031
+ app.post("/api/git/device/start", async (req, res) => {
1032
+ if (typeof fetch !== "function") {
1033
+ return res.status(500).json({ error: "Node 18+ required." });
1034
+ }
1035
+ const b = req.body || {};
1036
+ const client_id = device_client_id(b);
1037
+ if (!client_id) {
1038
+ return res.status(400).json({
1039
+ error: "No GITHUB_CLIENT_ID set. Create a GitHub OAuth App with Device Flow enabled, then set GITHUB_CLIENT_ID (or pass client_id).",
1040
+ });
1041
+ }
1042
+ const scope = (b.scope && String(b.scope)) || "repo";
1043
+ try {
1044
+ const r = await fetch(`${GITHUB_OAUTH}/login/device/code`, {
1045
+ method: "POST",
1046
+ headers: { accept: "application/json", "content-type": "application/json" },
1047
+ body: JSON.stringify({ client_id, scope }),
1048
+ });
1049
+ const j = await r.json();
1050
+ if (!r.ok || j.error) {
1051
+ return res
1052
+ .status(400)
1053
+ .json({ error: j.error_description || j.error || "device code request failed" });
1054
+ }
1055
+ res.json({
1056
+ user_code: j.user_code,
1057
+ verification_uri: j.verification_uri,
1058
+ device_code: j.device_code,
1059
+ interval: j.interval,
1060
+ expires_in: j.expires_in,
1061
+ });
1062
+ } catch (e) {
1063
+ res.status(502).json({ error: String((e && e.message) || e) });
1064
+ }
1065
+ });
1066
+
1067
+ // Step 2: poll for the access token (frontend polls on the given interval).
1068
+ app.post("/api/git/device/poll", async (req, res) => {
1069
+ if (typeof fetch !== "function") {
1070
+ return res.status(500).json({ error: "Node 18+ required." });
1071
+ }
1072
+ const b = req.body || {};
1073
+ const client_id = device_client_id(b);
1074
+ if (!client_id || !b.device_code) {
1075
+ return res.status(400).json({ error: "Missing client_id or device_code" });
1076
+ }
1077
+ try {
1078
+ const r = await fetch(`${GITHUB_OAUTH}/login/oauth/access_token`, {
1079
+ method: "POST",
1080
+ headers: { accept: "application/json", "content-type": "application/json" },
1081
+ body: JSON.stringify({
1082
+ client_id,
1083
+ device_code: b.device_code,
1084
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1085
+ }),
1086
+ });
1087
+ const j = await r.json();
1088
+ if (j.access_token) {
1089
+ return res.json({ access_token: j.access_token, scope: j.scope });
1090
+ }
1091
+ if (j.error === "authorization_pending") return res.json({ pending: true });
1092
+ if (j.error === "slow_down") {
1093
+ return res.json({ pending: true, slow_down: true, interval: j.interval });
1094
+ }
1095
+ res.status(400).json({ error: j.error_description || j.error || "authorization failed" });
1096
+ } catch (e) {
1097
+ res.status(502).json({ error: String((e && e.message) || e) });
1098
+ }
1099
+ });
1100
+
1101
+ // List the repos the authenticated user can access (paginated, newest first).
1102
+ app.post("/api/git/repos", async (req, res) => {
1103
+ const token = git_guard(req, res);
1104
+ if (!token) return;
1105
+ const b = req.body || {};
1106
+ const page = Math.max(1, parseInt(b.page, 10) || 1);
1107
+ const org = b.org && String(b.org).trim();
1108
+ try {
1109
+ const r = await github(
1110
+ token,
1111
+ "GET",
1112
+ org
1113
+ ? `/orgs/${encodeURIComponent(org)}/repos?per_page=30&page=${page}&sort=updated`
1114
+ : `/user/repos?per_page=30&page=${page}&sort=updated&affiliation=owner,collaborator,organization_member`,
1115
+ );
1116
+ if (!r.ok) {
1117
+ return res
1118
+ .status(r.status)
1119
+ .json({ error: (r.json && r.json.message) || "HTTP " + r.status });
1120
+ }
1121
+ res.json({
1122
+ repos: (Array.isArray(r.json) ? r.json : []).map(map_repo),
1123
+ page,
1124
+ hasMore: Array.isArray(r.json) && r.json.length === 30,
1125
+ rate: r.rate,
1126
+ });
1127
+ } catch (e) {
1128
+ res.status(502).json({ error: String((e && e.message) || e) });
1129
+ }
1130
+ });
1131
+
1132
+ // --- Local clones (GitHub-Desktop-style) ---
1133
+ //
1134
+ // Shells out to the system `git` (no npm dep — we move to bundled dugite
1135
+ // when the app is packaged as Electron). Anchored on GitHub Desktop's
1136
+ // default clone dir (~/Documents/GitHub) so we reuse repos it already
1137
+ // cloned. GIT_TERMINAL_PROMPT=0 makes auth failures fail fast instead of
1138
+ // hanging on a credential prompt.
1139
+ const git_base_dir = () => path.join(os.homedir(), "Documents", "GitHub");
1140
+
1141
+ const git_exec = (args, cwd, timeout = 120000) =>
1142
+ new Promise((resolve) => {
1143
+ execFile(
1144
+ "git",
1145
+ args,
1146
+ {
1147
+ cwd,
1148
+ windowsHide: true,
1149
+ maxBuffer: 16 * 1024 * 1024,
1150
+ timeout,
1151
+ killSignal: "SIGKILL",
1152
+ // Never block on an interactive credential prompt — fail fast
1153
+ // instead. GIT_TERMINAL_PROMPT kills terminal prompts;
1154
+ // GCM_INTERACTIVE=never stops the Windows credential-manager
1155
+ // GUI; GIT_ASKPASS=echo defeats askpass helpers.
1156
+ env: {
1157
+ ...process.env,
1158
+ GIT_TERMINAL_PROMPT: "0",
1159
+ GCM_INTERACTIVE: "never",
1160
+ GIT_ASKPASS: "echo",
1161
+ },
1162
+ },
1163
+ (err, stdout, stderr) => {
1164
+ resolve({
1165
+ ok: !err,
1166
+ timedOut: !!(err && err.killed),
1167
+ stdout: String(stdout || ""),
1168
+ stderr: String(stderr || ""),
1169
+ });
1170
+ },
1171
+ );
1172
+ });
1173
+
1174
+ // Read the origin's owner/repo straight from .git/config (a file read — no
1175
+ // `git` subprocess), used to match local clones against GitHub repos.
1176
+ const repo_full_name = (dir) => {
1177
+ try {
1178
+ const cfg = fs.readFileSync(path.join(dir, ".git", "config"), "utf8");
1179
+ const m = cfg.match(/\[remote "origin"\][^[]*?url\s*=\s*(.+)/);
1180
+ if (m) {
1181
+ const mm = m[1].trim().replace(/\.git$/, "").match(/github\.com[/:]([^/]+\/[^/\s]+)$/);
1182
+ if (mm) return mm[1];
1183
+ }
1184
+ } catch {
1185
+ /* no config / unreadable */
1186
+ }
1187
+ return null;
1188
+ };
1189
+
1190
+ // List local clones under the base dir, flagging hdocbooks and reporting
1191
+ // branch + ahead/behind + dirty (GitHub-Desktop's repo list). Process
1192
+ // spawns are the bottleneck on Windows, so origin is read from .git/config
1193
+ // (no spawn) and the one remaining `git status` per repo runs in PARALLEL.
1194
+ app.post("/api/git/local/list", async (req, res) => {
1195
+ const base = git_base_dir();
1196
+ let names = [];
1197
+ try {
1198
+ names = fs
1199
+ .readdirSync(base, { withFileTypes: true })
1200
+ .filter((d) => d.isDirectory())
1201
+ .map((d) => d.name);
1202
+ } catch {
1203
+ return res.json({ base, repos: [], note: "clone directory not found yet" });
1204
+ }
1205
+
1206
+ const candidates = names
1207
+ .map((name) => ({ name, dir: path.join(base, name) }))
1208
+ .filter((c) => fs.existsSync(path.join(c.dir, ".git")));
1209
+
1210
+ const repos = await Promise.all(
1211
+ candidates.map(async (c) => {
1212
+ const status = await git_exec(["status", "--porcelain", "-b"], c.dir, 15000);
1213
+ let branch = "";
1214
+ let ahead = 0;
1215
+ let behind = 0;
1216
+ let dirty = 0;
1217
+ if (status.ok) {
1218
+ for (const line of status.stdout.split("\n")) {
1219
+ if (line.startsWith("## ")) {
1220
+ branch = line.slice(3).split("...")[0].trim();
1221
+ const a = line.match(/ahead (\d+)/);
1222
+ const b = line.match(/behind (\d+)/);
1223
+ if (a) ahead = parseInt(a[1], 10) || 0;
1224
+ if (b) behind = parseInt(b[1], 10) || 0;
1225
+ } else if (line.trim()) {
1226
+ dirty++;
1227
+ }
1228
+ }
1229
+ }
1230
+ return {
1231
+ name: c.name,
1232
+ path: c.dir,
1233
+ isHdoc: fs.existsSync(path.join(c.dir, "hdocbook-project.json")),
1234
+ full_name: repo_full_name(c.dir),
1235
+ branch,
1236
+ ahead,
1237
+ behind,
1238
+ dirty,
1239
+ };
1240
+ }),
1241
+ );
1242
+
1243
+ repos.sort((a, b) => a.name.localeCompare(b.name));
1244
+ res.json({ base, repos });
1245
+ });
1246
+
1247
+ // Sync a local clone: fast-forward pull, authenticated with the token so
1248
+ // it never blocks on the OS credential manager, with a hard timeout.
1249
+ app.post("/api/git/local/sync", async (req, res) => {
1250
+ const b = req.body || {};
1251
+ const dir = String(b.path || "");
1252
+ if (!dir || !fs.existsSync(path.join(dir, ".git"))) {
1253
+ return res.status(400).json({ error: "Not a local git repository" });
1254
+ }
1255
+ const token = (b.token && String(b.token).trim()) || process.env.GITHUB_TOKEN || "";
1256
+
1257
+ // Current branch + origin (local, fast).
1258
+ const head = await git_exec(["rev-parse", "--abbrev-ref", "HEAD"], dir, 15000);
1259
+ const branch = head.ok ? head.stdout.trim() : "";
1260
+ const origin = await git_exec(["remote", "get-url", "origin"], dir, 15000);
1261
+ const m = origin.ok
1262
+ ? origin.stdout.trim().replace(/\.git$/, "").match(/github\.com[/:]([^/]+)\/([^/]+)$/)
1263
+ : null;
1264
+
1265
+ // Prefer a token-authenticated pull (no credential-manager dependency).
1266
+ let pull;
1267
+ if (token && m && branch) {
1268
+ const authed = `https://x-access-token:${token}@github.com/${m[1]}/${m[2]}.git`;
1269
+ pull = await git_exec(["pull", "--ff-only", authed, branch], dir, 60000);
1270
+ } else {
1271
+ pull = await git_exec(["pull", "--ff-only"], dir, 60000);
1272
+ }
1273
+
1274
+ const status = await git_exec(["status", "--porcelain", "-b"], dir, 15000);
1275
+ let ahead = 0;
1276
+ let behind = 0;
1277
+ let dirty = 0;
1278
+ if (status.ok) {
1279
+ for (const line of status.stdout.split("\n")) {
1280
+ if (line.startsWith("## ")) {
1281
+ const a = line.match(/ahead (\d+)/);
1282
+ const bh = line.match(/behind (\d+)/);
1283
+ if (a) ahead = parseInt(a[1], 10) || 0;
1284
+ if (bh) behind = parseInt(bh[1], 10) || 0;
1285
+ } else if (line.trim()) dirty++;
1286
+ }
1287
+ }
1288
+
1289
+ const strip = (s) => (token ? String(s).split(token).join("***") : String(s));
1290
+ const out = pull.stdout + pull.stderr;
1291
+ let message;
1292
+ if (pull.timedOut) {
1293
+ message = "Timed out — check your network or sign in again.";
1294
+ } else if (!pull.ok) {
1295
+ message = strip((pull.stderr || pull.stdout || "Sync failed").trim().split("\n")[0]);
1296
+ } else if (/already up to date/i.test(out)) {
1297
+ message = "Already up to date";
1298
+ } else {
1299
+ const stat = (out.match(/\d+ files? changed[^\n]*/) || [])[0];
1300
+ message = "Updated" + (stat ? ` — ${stat}` : "");
1301
+ }
1302
+ res.json({ ok: pull.ok && !pull.timedOut, message, ahead, behind, dirty });
1303
+ });
1304
+
1305
+ // Clone a repo into the base dir (reuses the device-flow/PAT token for the
1306
+ // transfer, then strips it from the stored remote so it isn't persisted).
1307
+ app.post("/api/git/local/clone", async (req, res) => {
1308
+ const token = git_guard(req, res);
1309
+ if (!token) return;
1310
+ const b = req.body || {};
1311
+ const { owner, repo } = split_repo(b.repo);
1312
+ if (!owner || !repo) {
1313
+ return res.status(400).json({ error: "Repository must be in owner/name form" });
1314
+ }
1315
+ const base = git_base_dir();
1316
+ try {
1317
+ fs.mkdirSync(base, { recursive: true });
1318
+ } catch {
1319
+ /* ignore */
1320
+ }
1321
+ const dest = path.join(base, repo);
1322
+ if (fs.existsSync(dest)) {
1323
+ return res.status(409).json({ error: "Already cloned locally", path: dest });
1324
+ }
1325
+ const auth_url = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
1326
+ const clean_url = `https://github.com/${owner}/${repo}.git`;
1327
+ const r = await git_exec(["clone", auth_url, dest], base);
1328
+ if (!r.ok) {
1329
+ return res.status(502).json({
1330
+ error: (r.stderr || r.stdout || "clone failed").split(token).join("***"),
1331
+ });
1332
+ }
1333
+ // Don't leave the token in .git/config — reset to the clean URL.
1334
+ await git_exec(["remote", "set-url", "origin", clean_url], dest);
1335
+ res.json({ ok: true, path: dest, name: repo });
1336
+ });
1337
+
1338
+ app.post("/api/pages", (req, res) => {
1339
+ const body = req.body || {};
1340
+ try {
1341
+ const content = typeof body.content === "string" ? body.content : "";
1342
+ const created = toc.create_file(body.path, content);
1343
+ note_write(path.join(source_path, created), content);
1344
+ res.json({ created, ...toc.list_files() });
1345
+ } catch (e) {
1346
+ res.status(400).json({ error: String((e && e.message) || e) });
1347
+ }
1348
+ });
1349
+
1350
+ app.post("/api/pages/folder", (req, res) => {
1351
+ const body = req.body || {};
1352
+ try {
1353
+ const created = toc.create_folder(body.path);
1354
+ res.json({ created, ...toc.list_files() });
1355
+ } catch (e) {
1356
+ res.status(400).json({ error: String((e && e.message) || e) });
1357
+ }
1358
+ });
1359
+
1360
+ app.delete("/api/pages", (req, res) => {
1361
+ const body = req.body || {};
1362
+ try {
1363
+ const deleted = toc.delete_file(body.path);
1364
+ res.json({ deleted, ...toc.list_files() });
1365
+ } catch (e) {
1366
+ res.status(400).json({ error: String((e && e.message) || e) });
1367
+ }
1368
+ });
1369
+
1370
+ // Page content addressed by file path (works for orphan pages too).
1371
+ app.get("/api/pagefile", (req, res) => {
1372
+ try {
1373
+ const { abs, rel } = toc._safe_rel(String(req.query.path || ""), {
1374
+ dir: false,
1375
+ });
1376
+ if (!fs.existsSync(abs)) {
1377
+ return res.status(404).json({ error: "File not found", file: rel });
1378
+ }
1379
+ const content_txt = fs.readFileSync(abs, "utf8");
1380
+ res.json({ file: rel, content: content_txt, etag: sha1(content_txt) });
1381
+ } catch (e) {
1382
+ res.status(400).json({ error: String((e && e.message) || e) });
1383
+ }
1384
+ });
1385
+
1386
+ app.put("/api/pagefile", (req, res) => {
1387
+ const body = req.body || {};
1388
+ if (typeof body.content !== "string") {
1389
+ return res.status(400).json({ error: "Missing content" });
1390
+ }
1391
+ try {
1392
+ const { abs, rel } = toc._safe_rel(String(req.query.path || ""), {
1393
+ dir: false,
1394
+ });
1395
+ // Optimistic concurrency: refuse to overwrite an external change.
1396
+ if (body.baseEtag && fs.existsSync(abs)) {
1397
+ const current = fs.readFileSync(abs, "utf8");
1398
+ if (sha1(current) !== body.baseEtag) {
1399
+ return res
1400
+ .status(409)
1401
+ .json({ error: "conflict", current, etag: sha1(current) });
1402
+ }
1403
+ }
1404
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1405
+ fs.writeFileSync(abs, body.content, "utf8");
1406
+ note_write(abs, body.content);
1407
+ res.json({
1408
+ ok: true,
1409
+ file: rel,
1410
+ bytes: Buffer.byteLength(body.content, "utf8"),
1411
+ etag: sha1(body.content),
1412
+ });
1413
+ } catch (e) {
1414
+ res.status(400).json({ error: String((e && e.message) || e) });
1415
+ }
1416
+ });
1417
+
1418
+ // (3) Editor SPA. Serve static assets verbatim (no variable expansion —
1419
+ // these are app assets, not book content), then fall back to index.html
1420
+ // so client-side routing works.
1421
+ app.use(express.static(editor_path, { index: false }));
1422
+
1423
+ app.get("/{*splat}", (req, res) => {
1424
+ const index_file = path.join(editor_path, "index.html");
1425
+ if (fs.existsSync(index_file)) {
1426
+ res.setHeader("Content-Type", "text/html");
1427
+ res.sendFile(index_file);
1428
+ return;
1429
+ }
1430
+ // The editor UI hasn't been built yet (no editor/dist). Guide the user
1431
+ // rather than returning a bare 404.
1432
+ res.status(503);
1433
+ res.setHeader("Content-Type", "text/html");
1434
+ res.send(
1435
+ "<h1>Editor UI not built</h1>" +
1436
+ "<p>Run the Vite build to generate <code>editor/dist</code>:</p>" +
1437
+ "<pre>cd editor &amp;&amp; npm run build</pre>" +
1438
+ "<p>For UI development with hot reload, run <code>npm run dev</code> in <code>editor/</code> instead (it proxies to this server).</p>",
1439
+ );
1440
+ });
1441
+
1442
+ // Bind to loopback only: this server can write to the book source.
1443
+ const server = app.listen(port, "127.0.0.1", () => {
1444
+ const port = server.address().port;
1445
+ console.log("Editor listening at http://127.0.0.1:%s", port);
1446
+ console.log(`Document source path is: ${source_path}`);
1447
+ });
1448
+ };
1449
+ })();