@imenam/mcp-documentor 1.0.92 → 1.0.94

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.
Files changed (83) hide show
  1. package/README.md +343 -327
  2. package/dist/src/core/git-sync.js +1 -0
  3. package/dist/src/core/publication-store.d.ts +76 -0
  4. package/dist/src/core/publication-store.js +180 -0
  5. package/dist/src/gui-worker.js +132 -1
  6. package/dist/src/index.js +110 -3
  7. package/gui/dist/assets/DocContent-B2hgJIHP.js +346 -0
  8. package/gui/dist/assets/DocContent-BxncLHIS.css +1 -0
  9. package/gui/dist/assets/arc-3c1bsell.js +1 -0
  10. package/gui/dist/assets/architectureDiagram-3BPJPVTR-gOvE5Nh-.js +36 -0
  11. package/gui/dist/assets/{blockDiagram-GPEHLZMM-CgILcS8Q.js → blockDiagram-GPEHLZMM-d2PyOGgB.js} +1 -1
  12. package/gui/dist/assets/{c4Diagram-AAUBKEIU-ByGBdJcU.js → c4Diagram-AAUBKEIU-Fq_MnzGd.js} +1 -1
  13. package/gui/dist/assets/channel-D0TrBTBU.js +1 -0
  14. package/gui/dist/assets/chunk-2J33WTMH-C8rPGvzC.js +1 -0
  15. package/gui/dist/assets/chunk-4BX2VUAB-CMO6CGqC.js +1 -0
  16. package/gui/dist/assets/chunk-55IACEB6-ewoEucdu.js +1 -0
  17. package/gui/dist/assets/{chunk-727SXJPM-B0BlALdx.js → chunk-727SXJPM-DVlayzbx.js} +1 -1
  18. package/gui/dist/assets/{chunk-AQP2D5EJ-DlKhB6LK.js → chunk-AQP2D5EJ-CahwsWX2.js} +1 -1
  19. package/gui/dist/assets/{chunk-FMBD7UC4-DBTqXHP1.js → chunk-FMBD7UC4-CTfqQvPB.js} +1 -1
  20. package/gui/dist/assets/chunk-ND2GUHAM-D16nBstn.js +1 -0
  21. package/gui/dist/assets/chunk-QZHKN3VN-BhYm6b_1.js +1 -0
  22. package/gui/dist/assets/classDiagram-4FO5ZUOK-BZarwo9C.js +1 -0
  23. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BZarwo9C.js +1 -0
  24. package/gui/dist/assets/cose-bilkent-S5V4N54A-BMWhCTQz.js +1 -0
  25. package/gui/dist/assets/dagre-BM42HDAG-B8q9fjYt.js +4 -0
  26. package/gui/dist/assets/diagram-2AECGRRQ-BEQd6l9g.js +43 -0
  27. package/gui/dist/assets/diagram-5GNKFQAL-CYSk25NC.js +10 -0
  28. package/gui/dist/assets/{diagram-KO2AKTUF-B-LnaXtr.js → diagram-KO2AKTUF-pYuzMimB.js} +1 -1
  29. package/gui/dist/assets/diagram-LMA3HP47-Coes1K5H.js +24 -0
  30. package/gui/dist/assets/diagram-OG6HWLK6-B9QD3Aih.js +24 -0
  31. package/gui/dist/assets/{erDiagram-TEJ5UH35-B7jlXuub.js → erDiagram-TEJ5UH35-BaTQh6lK.js} +1 -1
  32. package/gui/dist/assets/{flowDiagram-I6XJVG4X-BjWyZsCs.js → flowDiagram-I6XJVG4X-CD3j0V3z.js} +1 -1
  33. package/gui/dist/assets/ganttDiagram-6RSMTGT7-CI8luwIp.js +292 -0
  34. package/gui/dist/assets/gitGraphDiagram-PVQCEYII-CZicCj5w.js +106 -0
  35. package/gui/dist/assets/infoDiagram-5YYISTIA-BBBZLBfU.js +2 -0
  36. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-De4Ds8bG.js → ishikawaDiagram-YF4QCWOH-JzhvbF8X.js} +1 -1
  37. package/gui/dist/assets/{journeyDiagram-JHISSGLW-Cjpgxilg.js → journeyDiagram-JHISSGLW-z2Fq1AWA.js} +1 -1
  38. package/gui/dist/assets/{kanban-definition-UN3LZRKU-D8Y_Af2d.js → kanban-definition-UN3LZRKU-CsERU4RU.js} +1 -1
  39. package/gui/dist/assets/linear-bwdTeoS5.js +1 -0
  40. package/gui/dist/assets/main-BB0ARNyc.js +10 -0
  41. package/gui/dist/assets/main-DDy8gF40.css +1 -0
  42. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-Cq_fBarD.js → mindmap-definition-RKZ34NQL-Hhnd5vi2.js} +1 -1
  43. package/gui/dist/assets/{pieDiagram-4H26LBE5-LGkTEx-6.js → pieDiagram-4H26LBE5-DCSptoQo.js} +1 -1
  44. package/gui/dist/assets/public-Bds7tw8V.css +1 -0
  45. package/gui/dist/assets/public-D8L9bJEM.js +1 -0
  46. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-mgy_9RRW.js → quadrantDiagram-W4KKPZXB-CFG_TBfZ.js} +1 -1
  47. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-B_d8OEc3.js → requirementDiagram-4Y6WPE33-DUHthbad.js} +1 -1
  48. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-BgBLM80u.js → sankeyDiagram-5OEKKPKP-CcivnX5S.js} +1 -1
  49. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-B9GkqtWb.js → sequenceDiagram-3UESZ5HK-DD3f-L7G.js} +1 -1
  50. package/gui/dist/assets/stateDiagram-AJRCARHV-DP6QuANT.js +1 -0
  51. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DCHGEDGQ.js +1 -0
  52. package/gui/dist/assets/{timeline-definition-PNZ67QCA-Dyx58rRr.js → timeline-definition-PNZ67QCA-DpSvFfP9.js} +1 -1
  53. package/gui/dist/assets/{vennDiagram-CIIHVFJN-IypthVr7.js → vennDiagram-CIIHVFJN-DU7Clt-k.js} +1 -1
  54. package/gui/dist/assets/{wardley-L42UT6IY-DkLzWKUw.js → wardley-L42UT6IY-By2c-zj1.js} +1 -1
  55. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-98VafNiZ.js → wardleyDiagram-YWT4CUSO-BJeFdhb5.js} +1 -1
  56. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-dZj2dDaC.js → xychartDiagram-2RQKCTM6-ZeDSX3nQ.js} +1 -1
  57. package/gui/dist/index.html +4 -2
  58. package/gui/dist/public.html +17 -0
  59. package/package.json +1 -1
  60. package/gui/dist/assets/arc-BbedxJ3W.js +0 -1
  61. package/gui/dist/assets/architectureDiagram-3BPJPVTR-DF3IYwLZ.js +0 -36
  62. package/gui/dist/assets/channel-CIJsZK_a.js +0 -1
  63. package/gui/dist/assets/chunk-2J33WTMH-DIk0ZeJ6.js +0 -1
  64. package/gui/dist/assets/chunk-4BX2VUAB-fF78mC-G.js +0 -1
  65. package/gui/dist/assets/chunk-55IACEB6-BzoqbAWn.js +0 -1
  66. package/gui/dist/assets/chunk-ND2GUHAM-BmMICe1L.js +0 -1
  67. package/gui/dist/assets/chunk-QZHKN3VN-DyEBqCzf.js +0 -1
  68. package/gui/dist/assets/classDiagram-4FO5ZUOK-LzEGnqT7.js +0 -1
  69. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-LzEGnqT7.js +0 -1
  70. package/gui/dist/assets/cose-bilkent-S5V4N54A-DMkNiOVz.js +0 -1
  71. package/gui/dist/assets/dagre-BM42HDAG-CEGX5APR.js +0 -4
  72. package/gui/dist/assets/diagram-2AECGRRQ-DHo40rfh.js +0 -43
  73. package/gui/dist/assets/diagram-5GNKFQAL-CPbyX-Jj.js +0 -10
  74. package/gui/dist/assets/diagram-LMA3HP47-gjnm6GxN.js +0 -24
  75. package/gui/dist/assets/diagram-OG6HWLK6-B_qc7WBs.js +0 -24
  76. package/gui/dist/assets/ganttDiagram-6RSMTGT7-CGdvXL_7.js +0 -292
  77. package/gui/dist/assets/gitGraphDiagram-PVQCEYII-TxUcHrcr.js +0 -106
  78. package/gui/dist/assets/index-ARDnpfWR.css +0 -1
  79. package/gui/dist/assets/index-CrG0yj-K.js +0 -355
  80. package/gui/dist/assets/infoDiagram-5YYISTIA-BElIHd-R.js +0 -2
  81. package/gui/dist/assets/linear-BB2LhUqA.js +0 -1
  82. package/gui/dist/assets/stateDiagram-AJRCARHV-UoRM2vqX.js +0 -1
  83. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-B7m-qaHs.js +0 -1
@@ -22,6 +22,7 @@ const GITIGNORE_CONTENT = [
22
22
  "export-bundles.json",
23
23
  "local-state.json",
24
24
  "blob-cache/",
25
+ "publications/",
25
26
  "",
26
27
  ].join("\n");
27
28
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Published documents, in `<data dir>/publications/`. The sync repository's .gitignore excludes the
3
+ * directory: a publication is served by this machine, and its link only works here.
4
+ *
5
+ * publications/index.json every publication
6
+ * publications/<id>/<snapshot>/content.md the frozen content
7
+ * publications/<id>/<snapshot>/img/<p>/<f> the images it references, copied when frozen
8
+ *
9
+ * Each freeze writes a new snapshot directory before the index points at it, so a reader always
10
+ * finds a complete snapshot.
11
+ */
12
+ export interface Publication {
13
+ id: string;
14
+ /** Secret part of the public link. Whoever holds it can read the frozen content. */
15
+ token: string;
16
+ project: string;
17
+ docId: string;
18
+ /** Title of the document when it was frozen. */
19
+ title: string;
20
+ /** History version frozen, or null when the document has no recorded history. */
21
+ version: number | null;
22
+ /** Version note, "" when none was given. */
23
+ note: string;
24
+ /** Hash of the frozen title and content, compared with the current document. */
25
+ contentHash: string;
26
+ /** Snapshot directory under `<id>/`. */
27
+ snapshot: string;
28
+ createdAt: string;
29
+ /** When the current snapshot was frozen. */
30
+ publishedAt: string;
31
+ }
32
+ /** Content and images of a document at the moment it is frozen. */
33
+ export interface FreezeInput {
34
+ project: string;
35
+ docId: string;
36
+ title: string;
37
+ content: string;
38
+ version: number | null;
39
+ note: string;
40
+ }
41
+ export declare const MAX_NOTE_LENGTH = 500;
42
+ export declare function hashDocContent(title: string, content: string): string;
43
+ /** Project assets referenced by a document, as decoded `{ project, file }` pairs. */
44
+ export declare function referencedImages(content: string): Array<{
45
+ project: string;
46
+ file: string;
47
+ }>;
48
+ /** A single path segment that cannot climb out of its directory. */
49
+ export declare function isSafeSegment(segment: string): boolean;
50
+ export declare class PublicationStore {
51
+ private readonly dataDir;
52
+ constructor(dataDir: string);
53
+ private get rootDir();
54
+ private get indexPath();
55
+ list(): Publication[];
56
+ get(id: string): Publication | undefined;
57
+ findByDoc(project: string, docId: string): Publication | undefined;
58
+ /** The publication a public link names, compared in constant time. */
59
+ findByToken(token: string): Publication | undefined;
60
+ /** Frozen markdown of a publication. */
61
+ readContent(publication: Publication): string;
62
+ /** Path of a frozen image, or undefined when the snapshot holds no such image. */
63
+ imagePath(publication: Publication, project: string, file: string): string | undefined;
64
+ publish(input: FreezeInput): Publication;
65
+ /** Freezes the current content under the same link. */
66
+ update(id: string, input: FreezeInput): Publication;
67
+ /** Revokes the link: the publication and its snapshots are deleted. */
68
+ unpublish(id: string): Publication;
69
+ /** Follows a document to its new place. Returns true when a publication was moved. */
70
+ moveDoc(project: string, docId: string, newProject: string, newDocId: string): boolean;
71
+ private require;
72
+ private snapshotDir;
73
+ /** Writes a new snapshot directory and returns its name. */
74
+ private freeze;
75
+ private writeIndex;
76
+ }
@@ -0,0 +1,180 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ /** Image URLs a document uses for project assets: `/api/images/<project>/<file>`. */
5
+ const IMAGE_URL = /(?:^|[\s("'/])\/?api\/images\/([^/\s)"'?#]+)\/([^/\s)"'?#]+)/g;
6
+ export const MAX_NOTE_LENGTH = 500;
7
+ export function hashDocContent(title, content) {
8
+ return crypto.createHash("sha256").update(title).update("\n").update(content).digest("hex");
9
+ }
10
+ /** Project assets referenced by a document, as decoded `{ project, file }` pairs. */
11
+ export function referencedImages(content) {
12
+ const found = new Map();
13
+ for (const match of content.matchAll(IMAGE_URL)) {
14
+ let project;
15
+ let file;
16
+ try {
17
+ project = decodeURIComponent(match[1]);
18
+ file = decodeURIComponent(match[2]);
19
+ }
20
+ catch {
21
+ continue;
22
+ }
23
+ if (!isSafeSegment(project) || !isSafeSegment(file))
24
+ continue;
25
+ found.set(`${project}/${file}`, { project, file });
26
+ }
27
+ return [...found.values()];
28
+ }
29
+ /** A single path segment that cannot climb out of its directory. */
30
+ export function isSafeSegment(segment) {
31
+ return segment !== "" && segment !== "." && segment !== ".." && !/[\\/\0]/.test(segment);
32
+ }
33
+ function normalizeNote(note) {
34
+ if (note === undefined || note === null)
35
+ return "";
36
+ if (typeof note !== "string")
37
+ throw new Error("note must be a string");
38
+ const trimmed = note.trim();
39
+ if (trimmed.length > MAX_NOTE_LENGTH) {
40
+ throw new Error(`note is limited to ${MAX_NOTE_LENGTH} characters`);
41
+ }
42
+ return trimmed;
43
+ }
44
+ export class PublicationStore {
45
+ dataDir;
46
+ constructor(dataDir) {
47
+ this.dataDir = dataDir;
48
+ }
49
+ get rootDir() {
50
+ return path.join(this.dataDir, "publications");
51
+ }
52
+ get indexPath() {
53
+ return path.join(this.rootDir, "index.json");
54
+ }
55
+ list() {
56
+ if (!fs.existsSync(this.indexPath))
57
+ return [];
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(this.indexPath, "utf-8"));
60
+ if (!Array.isArray(parsed))
61
+ throw new Error("not an array");
62
+ return parsed;
63
+ }
64
+ catch (err) {
65
+ throw new Error(`${this.indexPath} is not a valid publication index (${err.message}) — fix or delete it.`);
66
+ }
67
+ }
68
+ get(id) {
69
+ return this.list().find((p) => p.id === id);
70
+ }
71
+ findByDoc(project, docId) {
72
+ return this.list().find((p) => p.project === project && p.docId === docId);
73
+ }
74
+ /** The publication a public link names, compared in constant time. */
75
+ findByToken(token) {
76
+ const given = Buffer.from(token);
77
+ return this.list().find((p) => {
78
+ const expected = Buffer.from(p.token);
79
+ return expected.length === given.length && crypto.timingSafeEqual(expected, given);
80
+ });
81
+ }
82
+ /** Frozen markdown of a publication. */
83
+ readContent(publication) {
84
+ return fs.readFileSync(path.join(this.snapshotDir(publication), "content.md"), "utf-8");
85
+ }
86
+ /** Path of a frozen image, or undefined when the snapshot holds no such image. */
87
+ imagePath(publication, project, file) {
88
+ if (!isSafeSegment(project) || !isSafeSegment(file))
89
+ return undefined;
90
+ const filePath = path.join(this.snapshotDir(publication), "img", project, file);
91
+ return fs.existsSync(filePath) ? filePath : undefined;
92
+ }
93
+ publish(input) {
94
+ if (this.findByDoc(input.project, input.docId)) {
95
+ throw new Error(`"${input.docId}" is already published in project "${input.project}"`);
96
+ }
97
+ const now = new Date().toISOString();
98
+ const id = crypto.randomBytes(8).toString("hex");
99
+ const snapshot = this.freeze(id, input);
100
+ const publication = {
101
+ id,
102
+ token: crypto.randomBytes(32).toString("base64url"),
103
+ project: input.project,
104
+ docId: input.docId,
105
+ title: input.title,
106
+ version: input.version,
107
+ note: normalizeNote(input.note),
108
+ contentHash: hashDocContent(input.title, input.content),
109
+ snapshot,
110
+ createdAt: now,
111
+ publishedAt: now,
112
+ };
113
+ this.writeIndex([...this.list(), publication]);
114
+ return publication;
115
+ }
116
+ /** Freezes the current content under the same link. */
117
+ update(id, input) {
118
+ const previous = this.require(id);
119
+ const snapshot = this.freeze(id, input);
120
+ const publication = {
121
+ ...previous,
122
+ title: input.title,
123
+ version: input.version,
124
+ note: normalizeNote(input.note),
125
+ contentHash: hashDocContent(input.title, input.content),
126
+ snapshot,
127
+ publishedAt: new Date().toISOString(),
128
+ };
129
+ this.writeIndex(this.list().map((p) => (p.id === id ? publication : p)));
130
+ fs.rmSync(path.join(this.rootDir, id, previous.snapshot), { recursive: true, force: true });
131
+ return publication;
132
+ }
133
+ /** Revokes the link: the publication and its snapshots are deleted. */
134
+ unpublish(id) {
135
+ const publication = this.require(id);
136
+ this.writeIndex(this.list().filter((p) => p.id !== id));
137
+ fs.rmSync(path.join(this.rootDir, id), { recursive: true, force: true });
138
+ return publication;
139
+ }
140
+ /** Follows a document to its new place. Returns true when a publication was moved. */
141
+ moveDoc(project, docId, newProject, newDocId) {
142
+ const publications = this.list();
143
+ const target = publications.find((p) => p.project === project && p.docId === docId);
144
+ if (!target)
145
+ return false;
146
+ this.writeIndex(publications.map((p) => (p === target ? { ...p, project: newProject, docId: newDocId } : p)));
147
+ return true;
148
+ }
149
+ require(id) {
150
+ const publication = this.get(id);
151
+ if (!publication)
152
+ throw new Error(`Publication "${id}" does not exist`);
153
+ return publication;
154
+ }
155
+ snapshotDir(publication) {
156
+ return path.join(this.rootDir, publication.id, publication.snapshot);
157
+ }
158
+ /** Writes a new snapshot directory and returns its name. */
159
+ freeze(id, input) {
160
+ const snapshot = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`;
161
+ const dir = path.join(this.rootDir, id, snapshot);
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ fs.writeFileSync(path.join(dir, "content.md"), input.content, "utf-8");
164
+ for (const { project, file } of referencedImages(input.content)) {
165
+ const source = path.join(this.dataDir, "projects", project, "assets", file);
166
+ if (!fs.existsSync(source))
167
+ continue;
168
+ const target = path.join(dir, "img", project, file);
169
+ fs.mkdirSync(path.dirname(target), { recursive: true });
170
+ fs.copyFileSync(source, target);
171
+ }
172
+ return snapshot;
173
+ }
174
+ writeIndex(publications) {
175
+ fs.mkdirSync(this.rootDir, { recursive: true });
176
+ const temp = `${this.indexPath}.${process.pid}.tmp`;
177
+ fs.writeFileSync(temp, JSON.stringify(publications, null, 2), "utf-8");
178
+ fs.renameSync(temp, this.indexPath);
179
+ }
180
+ }
@@ -15,6 +15,7 @@ import { fileURLToPath } from "url";
15
15
  import { createLogger, setupLogging } from "@imenam/mcp-gui-interface";
16
16
  import { resolveLogDir } from "./core/log-dir.js";
17
17
  import { isLogSource, logFileInfo, readGitHubLog, readServerLog } from "./core/log-reader.js";
18
+ import { isSafeSegment, PublicationStore } from "./core/publication-store.js";
18
19
  import matter from "gray-matter";
19
20
  import JSZip from "jszip";
20
21
  import puppeteer from "puppeteer-core";
@@ -178,6 +179,7 @@ const BROADCAST_TYPES = [
178
179
  "PROJECTS_CHANGED",
179
180
  "DOCS_CHANGED",
180
181
  "CODE_SOURCES_CHANGED",
182
+ "PUBLICATIONS_CHANGED",
181
183
  ];
182
184
  /** Number of pushed messages kept for clients catching up. */
183
185
  const CHANGE_JOURNAL_SIZE = 200;
@@ -233,6 +235,8 @@ const READ_ONLY_WRITE_ROUTES = [
233
235
  { method: "POST", prefix: "/api/sync" },
234
236
  { method: "POST", prefix: "/api/code-sources/apply" },
235
237
  { method: "POST", prefix: "/api/code-roots/apply" },
238
+ { method: "POST", prefix: "/api/publications" },
239
+ { method: "DELETE", prefix: "/api/publications" },
236
240
  ];
237
241
  app.use("*", async (c, next) => {
238
242
  if (isReadOnly()) {
@@ -413,6 +417,133 @@ app.post("/api/code-roots/apply", async (c) => {
413
417
  app.get("/api/github-token", (c) => relay(c, "GET_GITHUB_TOKEN", {}, 15000));
414
418
  app.get("/api/code-source-migration", (c) => relay(c, "GET_CODE_SOURCE_MIGRATION", {}, 5000));
415
419
  app.post("/api/code-source-migration/ack", (c) => relay(c, "ACK_CODE_SOURCE_MIGRATION", {}, 5000));
420
+ // --- Publications ---
421
+ // Written by the master, which owns the documents and their history.
422
+ app.get("/api/publications", (c) => relay(c, "LIST_PUBLICATIONS", { project: c.req.query("project") }, 15000));
423
+ app.post("/api/publications", async (c) => {
424
+ const { project, id, note } = await readJson(c);
425
+ if (!project || !id)
426
+ return c.json({ error: "project and id are required" }, 400);
427
+ return relay(c, "PUBLISH_DOC", { project, id, note }, 15000, 400);
428
+ });
429
+ app.post("/api/publications/:publicationId/update", async (c) => {
430
+ const { note } = await readJson(c);
431
+ return relay(c, "UPDATE_PUBLICATION", { publicationId: c.req.param("publicationId"), note }, 15000, 400);
432
+ });
433
+ app.delete("/api/publications/:publicationId", (c) => relay(c, "UNPUBLISH_DOC", { publicationId: c.req.param("publicationId") }, 15000, 400));
434
+ // --- Public pages ---
435
+ // `/public/<token>/` serves a frozen document to anyone holding the link, with no session. It is
436
+ // the only route a hub relays without authentication: everything it answers stays under it.
437
+ // Read straight from the publication store, so a page never waits on the master.
438
+ const publicationStore = new PublicationStore(dataDir);
439
+ /** Tokens are 32 random bytes in base64url. */
440
+ const PUBLIC_TOKEN = /^[A-Za-z0-9_-]{43}$/;
441
+ const PUBLIC_HEADERS = {
442
+ "X-Robots-Tag": "noindex, nofollow, noarchive",
443
+ "Referrer-Policy": "no-referrer",
444
+ "X-Content-Type-Options": "nosniff",
445
+ };
446
+ const PUBLIC_PAGE_CSP = [
447
+ "default-src 'self'",
448
+ "script-src 'self'",
449
+ "style-src 'self' 'unsafe-inline'",
450
+ "img-src 'self' data: https:",
451
+ "font-src 'self' data:",
452
+ "connect-src 'self'",
453
+ "object-src 'none'",
454
+ "base-uri 'none'",
455
+ "form-action 'none'",
456
+ "frame-ancestors 'none'",
457
+ ].join("; ");
458
+ const ASSET_MIME = {
459
+ js: "text/javascript; charset=utf-8",
460
+ css: "text/css; charset=utf-8",
461
+ svg: "image/svg+xml",
462
+ png: "image/png",
463
+ woff: "font/woff",
464
+ woff2: "font/woff2",
465
+ ttf: "font/ttf",
466
+ };
467
+ function publicResponse(body, status, headers) {
468
+ return new Response(body, { status, headers: { ...PUBLIC_HEADERS, ...headers } });
469
+ }
470
+ function publicNotFound() {
471
+ return publicResponse("Not found", 404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
472
+ }
473
+ /** Safe for the inside of a `<script type="application/json">` element. */
474
+ function jsonForScript(value) {
475
+ return JSON.stringify(value).replace(/</g, "\\u003c");
476
+ }
477
+ function escapeHtml(value) {
478
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
479
+ }
480
+ app.all("/public/*", (c) => {
481
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
482
+ return publicResponse("Method not allowed", 405, { Allow: "GET, HEAD", "Cache-Control": "no-store" });
483
+ }
484
+ let segments;
485
+ try {
486
+ segments = new URL(c.req.url).pathname.slice("/public/".length).split("/").map(decodeURIComponent);
487
+ }
488
+ catch {
489
+ return publicNotFound();
490
+ }
491
+ const [token, ...tail] = segments;
492
+ if (!PUBLIC_TOKEN.test(token))
493
+ return publicNotFound();
494
+ const publication = publicationStore.findByToken(token);
495
+ if (!publication)
496
+ return publicNotFound();
497
+ // Relative URLs of the page resolve under the token only when its URL ends with a slash.
498
+ if (tail.length === 0) {
499
+ return publicResponse(null, 302, { Location: `${encodeURIComponent(token)}/`, "Cache-Control": "no-store" });
500
+ }
501
+ if (tail.length === 1 && tail[0] === "") {
502
+ const shellPath = path.join(absoluteStaticRoot, "public.html");
503
+ if (!fs.existsSync(shellPath)) {
504
+ return publicResponse("GUI build not found", 500, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
505
+ }
506
+ const data = {
507
+ title: publication.title,
508
+ version: publication.version,
509
+ note: publication.note,
510
+ publishedAt: publication.publishedAt,
511
+ content: publicationStore.readContent(publication),
512
+ };
513
+ const html = fs
514
+ .readFileSync(shellPath, "utf8")
515
+ .replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(publication.title)}</title>`)
516
+ .replace("</head>", ` <script id="publication-data" type="application/json">${jsonForScript(data)}</script>\n </head>`);
517
+ return publicResponse(html, 200, {
518
+ "Content-Type": "text/html; charset=utf-8",
519
+ "Cache-Control": "no-store",
520
+ "Content-Security-Policy": PUBLIC_PAGE_CSP,
521
+ });
522
+ }
523
+ if (tail.length === 2 && tail[0] === "assets" && isSafeSegment(tail[1])) {
524
+ const filePath = path.join(absoluteStaticRoot, "assets", tail[1]);
525
+ if (!fs.existsSync(filePath))
526
+ return publicNotFound();
527
+ const ext = path.extname(filePath).toLowerCase().slice(1);
528
+ return publicResponse(fs.readFileSync(filePath), 200, {
529
+ "Content-Type": ASSET_MIME[ext] ?? "application/octet-stream",
530
+ "Cache-Control": "public, max-age=31536000, immutable",
531
+ });
532
+ }
533
+ if (tail.length === 3 && tail[0] === "img") {
534
+ const filePath = publicationStore.imagePath(publication, tail[1], tail[2]);
535
+ if (!filePath)
536
+ return publicNotFound();
537
+ const ext = path.extname(filePath).toLowerCase().slice(1);
538
+ return publicResponse(fs.readFileSync(filePath), 200, {
539
+ "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
540
+ "Cache-Control": "no-store",
541
+ // An SVG opened on its own must not run scripts under the hub's origin.
542
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
543
+ });
544
+ }
545
+ return publicNotFound();
546
+ });
416
547
  // --- Logs ---
417
548
  // Read straight from the log directory: both processes write there, and reading needs no IPC.
418
549
  const logDir = resolveLogDir(rootDir);
@@ -684,7 +815,7 @@ app.get("/api/export-zip/:id", async (c) => {
684
815
  docId = ref;
685
816
  }
686
817
  else {
687
- zip.file(`${ref}.md`, `# [Unqualified doc reference: ${ref} — no project given]
818
+ zip.file(`${ref}.md`, `# [Unqualified doc reference: ${ref} — no project given]
688
819
  `);
689
820
  continue;
690
821
  }
package/dist/src/index.js CHANGED
@@ -54,7 +54,8 @@ import { getImage } from "./tools/get-image.js";
54
54
  import { SyncEngine } from "./core/git-sync.js";
55
55
  import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
56
56
  import { listConflicts, getConflictHunks, resolveConflictHunks } from "./tools/conflicts.js";
57
- import { textSourceDescriptions, } from "../shared/schemas/doc.schema.js";
57
+ import { hashDocContent, PublicationStore } from "./core/publication-store.js";
58
+ import { MoveDocArgsSchema, textSourceDescriptions, } from "../shared/schemas/doc.schema.js";
58
59
  import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
59
60
  const __filename = fileURLToPath(import.meta.url);
60
61
  const __dirname = path.dirname(__filename);
@@ -109,6 +110,7 @@ historyStore.init(storageEngine.getDataDir());
109
110
  storageEngine.setHistoryStore(historyStore);
110
111
  const syncEngine = new SyncEngine(storageEngine);
111
112
  const localState = new LocalState(storageEngine.getDataDir());
113
+ const publicationStore = new PublicationStore(storageEngine.getDataDir());
112
114
  setBlobStoreDir(path.join(storageEngine.getDataDir(), "blob-cache"));
113
115
  setCodeRootProvider((project) => localState.getCodeRoot(project));
114
116
  setCodeSourceProvider({
@@ -247,6 +249,18 @@ function dispatchIPCMessage(msg) {
247
249
  return { success: true };
248
250
  });
249
251
  }
252
+ else if (msg.type === "LIST_PUBLICATIONS") {
253
+ replyToGUI(msg, "LIST_PUBLICATIONS_RESPONSE", () => listPublications(msg.data?.project || undefined));
254
+ }
255
+ else if (msg.type === "PUBLISH_DOC") {
256
+ replyToGUI(msg, "PUBLISH_DOC_RESPONSE", () => publishDoc(msg.data ?? {}));
257
+ }
258
+ else if (msg.type === "UPDATE_PUBLICATION") {
259
+ replyToGUI(msg, "UPDATE_PUBLICATION_RESPONSE", () => updatePublication(msg.data ?? {}));
260
+ }
261
+ else if (msg.type === "UNPUBLISH_DOC") {
262
+ replyToGUI(msg, "UNPUBLISH_DOC_RESPONSE", () => unpublishDoc(msg.data ?? {}));
263
+ }
250
264
  else {
251
265
  logger.debug(`Unhandled message from GUI: ${msg.type}`);
252
266
  }
@@ -916,6 +930,95 @@ async function handleGetDocVersionIPC(msg) {
916
930
  });
917
931
  }
918
932
  }
933
+ /** A publication as the GUI lists it: the stored record plus the state of the document it froze. */
934
+ async function describePublication(publication) {
935
+ const doc = await storageEngine.getDoc(publication.docId, publication.project);
936
+ const status = !doc
937
+ ? "missing"
938
+ : hashDocContent(doc.title, doc.content) === publication.contentHash
939
+ ? "current"
940
+ : "modified";
941
+ return {
942
+ id: publication.id,
943
+ token: publication.token,
944
+ project: publication.project,
945
+ docId: publication.docId,
946
+ title: publication.title,
947
+ version: publication.version,
948
+ note: publication.note,
949
+ createdAt: publication.createdAt,
950
+ publishedAt: publication.publishedAt,
951
+ status,
952
+ currentVersion: doc ? latestVersion(publication.project, publication.docId) : null,
953
+ };
954
+ }
955
+ function latestVersion(project, docId) {
956
+ return historyStore.listVersions(project, docId)[0]?.version ?? null;
957
+ }
958
+ function assertWritable() {
959
+ if (isReadOnly())
960
+ throw new Error("READ_ONLY mode: publishing is disabled");
961
+ }
962
+ function broadcastPublicationsChanged(project) {
963
+ broadcastToGUI({ type: "PUBLICATIONS_CHANGED", data: { project }, timestamp: new Date().toISOString() });
964
+ }
965
+ async function listPublications(project) {
966
+ const publications = publicationStore.list().filter((p) => !project || p.project === project);
967
+ return { publications: await Promise.all(publications.map(describePublication)) };
968
+ }
969
+ /** The document as it is now, frozen with the version note. */
970
+ async function freezeInput(project, docId, note) {
971
+ const doc = await storageEngine.getDoc(docId, project);
972
+ if (!doc)
973
+ throw new Error(`Doc "${docId}" not found in project "${project}"`);
974
+ return {
975
+ project,
976
+ docId,
977
+ title: doc.title,
978
+ content: doc.content,
979
+ version: latestVersion(project, docId),
980
+ note: note,
981
+ };
982
+ }
983
+ async function publishDoc(data) {
984
+ assertWritable();
985
+ if (!data.project || !data.id)
986
+ throw new Error("project and id are required");
987
+ const publication = publicationStore.publish(await freezeInput(data.project, data.id, data.note));
988
+ logger.info(`Published [${publication.project}] ${publication.docId}`);
989
+ broadcastPublicationsChanged(publication.project);
990
+ return { publication: await describePublication(publication) };
991
+ }
992
+ async function updatePublication(data) {
993
+ assertWritable();
994
+ const existing = data.publicationId ? publicationStore.get(data.publicationId) : undefined;
995
+ if (!existing)
996
+ throw new Error(`Publication "${data.publicationId}" does not exist`);
997
+ const publication = publicationStore.update(existing.id, await freezeInput(existing.project, existing.docId, data.note));
998
+ logger.info(`Publication of [${publication.project}] ${publication.docId} updated`);
999
+ broadcastPublicationsChanged(publication.project);
1000
+ return { publication: await describePublication(publication) };
1001
+ }
1002
+ async function unpublishDoc(data) {
1003
+ assertWritable();
1004
+ if (!data.publicationId)
1005
+ throw new Error("publicationId is required");
1006
+ const publication = publicationStore.unpublish(data.publicationId);
1007
+ logger.info(`Unpublished [${publication.project}] ${publication.docId}`);
1008
+ broadcastPublicationsChanged(publication.project);
1009
+ return { success: true };
1010
+ }
1011
+ /** A moved document keeps its publication: the link stays valid and its state follows the new path. */
1012
+ function movePublication(args) {
1013
+ const parsed = MoveDocArgsSchema.parse(args);
1014
+ const newProject = parsed.new_project ?? parsed.project;
1015
+ const moved = publicationStore.moveDoc(parsed.project, parsed.path, newProject, parsed.new_path ?? parsed.path);
1016
+ if (!moved)
1017
+ return;
1018
+ broadcastPublicationsChanged(parsed.project);
1019
+ if (newProject !== parsed.project)
1020
+ broadcastPublicationsChanged(newProject);
1021
+ }
919
1022
  guiLauncher = new GuiLauncher({
920
1023
  guiPath: path.join(__dirname, "gui-worker.js"),
921
1024
  env: { ...process.env },
@@ -1647,8 +1750,12 @@ async function handleToolCall(request) {
1647
1750
  return await checkLinks(storageEngine, args);
1648
1751
  case "delete_doc":
1649
1752
  return await deleteDoc(storageEngine, embeddingEngine, args);
1650
- case "move_doc":
1651
- return await moveDoc(storageEngine, embeddingEngine, args);
1753
+ case "move_doc": {
1754
+ const result = await moveDoc(storageEngine, embeddingEngine, args);
1755
+ if (!result.isError)
1756
+ movePublication(args);
1757
+ return result;
1758
+ }
1652
1759
  case "get_doc_tree":
1653
1760
  return await getDocTree(storageEngine, args);
1654
1761
  case "list_doc_versions":