@imenam/mcp-documentor 1.0.55 → 1.0.57
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/README.md +70 -0
- package/dist/shared/read-only.d.ts +4 -0
- package/dist/shared/read-only.js +14 -0
- package/dist/src/core/git-sync.d.ts +56 -0
- package/dist/src/core/git-sync.js +215 -0
- package/dist/src/core/storage-engine.d.ts +22 -0
- package/dist/src/core/storage-engine.js +62 -3
- package/dist/src/gui-worker.js +18 -0
- package/dist/src/index.js +450 -332
- package/dist/src/tools/sync.d.ts +17 -0
- package/dist/src/tools/sync.js +113 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/{arc-CCHCB-wD.js → arc-hl-9jVa3.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-CY9HTuIq.js → architectureDiagram-3BPJPVTR-Cv0e-QlI.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-BOVxufIP.js → blockDiagram-GPEHLZMM-DmqN7ioQ.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-D4gw0Vvh.js → c4Diagram-AAUBKEIU-CCmx3y3D.js} +1 -1
- package/gui/dist/assets/channel-Be8bJJxH.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-D9MZap0G.js → chunk-2J33WTMH-DkiBzADG.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-DZp7afnZ.js → chunk-4BX2VUAB-DNbC5-R6.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6--z45PUIk.js → chunk-55IACEB6-BDo8xvgP.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BpzsGHwS.js → chunk-727SXJPM-BXbVbdMj.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-BHrif5nf.js → chunk-AQP2D5EJ-BYle6aB3.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-BJnvL7mL.js → chunk-FMBD7UC4-BdIrLn1p.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-B30rNlK9.js → chunk-ND2GUHAM-BkjdsZf2.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-C30k0U0w.js → chunk-QZHKN3VN-pwVXVLDb.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-DxtDCVWj.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DxtDCVWj.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_ZPLp5P.js → cose-bilkent-S5V4N54A-COfRIcXU.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-2XyC0YBj.js → dagre-BM42HDAG-BnD4nd21.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-fNv6yQF_.js → diagram-2AECGRRQ-E9oTEn4C.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-BUlxr0NW.js → diagram-5GNKFQAL-oSJwag8_.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CtusNGfh.js → diagram-KO2AKTUF-B5ycmD2n.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-Dkq3FOEe.js → diagram-LMA3HP47-DUo9wv_e.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-rjhcMJNr.js → diagram-OG6HWLK6-C-0r9-AW.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-Bqmogkqv.js → erDiagram-TEJ5UH35-C8w0nNRI.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-BcYYq0Bh.js → flowDiagram-I6XJVG4X-CPZzt4CO.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-BGmJltMK.js → ganttDiagram-6RSMTGT7-B0TRw-bC.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-JIYjcd0T.js → gitGraphDiagram-PVQCEYII-CxuhZqVM.js} +1 -1
- package/gui/dist/assets/{index-BwW5YDD1.js → index-84YuA9I8.js} +75 -75
- package/gui/dist/assets/{infoDiagram-5YYISTIA-CJKrfqe4.js → infoDiagram-5YYISTIA-qLgkATyo.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-Bu4wBvlL.js → ishikawaDiagram-YF4QCWOH-Bv8EcedY.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-B2CtojGV.js → journeyDiagram-JHISSGLW-CO1wpi9j.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-CKbuCL_h.js → kanban-definition-UN3LZRKU-INpnb6ij.js} +1 -1
- package/gui/dist/assets/{linear-BdHWi7h7.js → linear-yh3g20kh.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-Dbf71zzi.js → mindmap-definition-RKZ34NQL-BzSJP5cs.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-P8RSqdBA.js → pieDiagram-4H26LBE5-DH9i355H.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-mVsJmQan.js → quadrantDiagram-W4KKPZXB-CNmW8Mck.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BfHNLWFb.js → requirementDiagram-4Y6WPE33-DIxitFst.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CF28kVcG.js → sankeyDiagram-5OEKKPKP-BI4KHeTZ.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Bvs2r29Y.js → sequenceDiagram-3UESZ5HK-CbMtHFNi.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-Cbg-3kIx.js → stateDiagram-AJRCARHV-DWXkOhjJ.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-Dv9pwdh3.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-ChYm8r8v.js → timeline-definition-PNZ67QCA-DGsBQPbg.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-CPcYOWG6.js → vennDiagram-CIIHVFJN-Dw5U_iYl.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-KWdiWaFE.js → wardley-L42UT6IY-DGOHyaD_.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DAgBnDcZ.js → wardleyDiagram-YWT4CUSO-DpOyo37L.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DwOk_srp.js → xychartDiagram-2RQKCTM6-BM5vPGiE.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/gui/dist/assets/channel-V0oafPxe.js +0 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-BZlXnsXv.js +0 -1
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BZlXnsXv.js +0 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-Cg1sS-MT.js +0 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Connect it to Cursor, Claude Desktop, or any MCP-compatible agent to let your AI
|
|
|
14
14
|
- **Proxy-aware** — integrates with the [proxy-setup](https://github.com/Imena/proxy-setup) for GUI exposure
|
|
15
15
|
- **Links & structure** — `check_links`, `get_doc_tree`, `move_doc`, and `git_diff_doc` for maintenance workflows
|
|
16
16
|
- **Image support** — upload images to a project's assets via `upload_image` (local file path) or directly from the GUI editor; images are stored per-project and rendered inline in the viewer and in PDF exports
|
|
17
|
+
- **GitHub sync** — share your documentation across machines by backing the data directory with a private GitHub repo; the repo/token are fixed by the administrator via environment variables, and the agent can only `pull_docs` / `push_docs` / `sync_docs`
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -58,6 +59,11 @@ Add to your MCP client config (e.g. Cursor `mcp.json`):
|
|
|
58
59
|
| `MCP_DOCUMENTOR_OPENAI_MODEL` | No | Embedding model (default: `text-embedding-3-small`) |
|
|
59
60
|
| `PROXY_URL` | No | URL of the proxy server to register and launch the GUI |
|
|
60
61
|
| `MCP_DATA_DIR` | No | Overrides the default data directory (see Data storage) |
|
|
62
|
+
| `READ_ONLY` | No | When set to `1`/`true`/`yes`, disables all create/update/delete tools and GUI write actions (read-only mode) |
|
|
63
|
+
| `MCP_DOCUMENTOR_SYNC_REMOTE` | No | HTTPS URL of the private GitHub repo to sync with; enables GitHub sync when set |
|
|
64
|
+
| `MCP_DOCUMENTOR_SYNC_BRANCH` | No | Branch to sync (default: `main`) |
|
|
65
|
+
| `MCP_DOCUMENTOR_SYNC_TOKEN` | No | Fine-grained GitHub PAT used for HTTPS auth (see GitHub sync) |
|
|
66
|
+
| `SYNC_PULL_ON_START` | No | Set to `false` to disable the automatic pull at server startup |
|
|
61
67
|
|
|
62
68
|
Optional `.env` at the package root (directory containing `package.json` discovered from the installed binary) is loaded when present.
|
|
63
69
|
|
|
@@ -82,6 +88,11 @@ Optional `.env` at the package root (directory containing `package.json` discove
|
|
|
82
88
|
| `list_projects` | List all documentation projects |
|
|
83
89
|
| `set_project` | Switch the active project **for the GUI only** (not for MCP tools) |
|
|
84
90
|
| `upload_image` | Upload an image from a local file path to a project's assets; returns the markdown link to insert |
|
|
91
|
+
| `sync_status` | Show branch, ahead/behind counts, conflicted files, and uncommitted changes |
|
|
92
|
+
| `pull_docs` | Pull updates from the remote (defensive fast-forward; reindexes new docs) |
|
|
93
|
+
| `push_docs` | Commit local doc changes and push them to the remote |
|
|
94
|
+
| `sync_docs` | Full sync: pull then push (push skipped if the pull reveals a conflict) |
|
|
95
|
+
| `resolve_sync_conflict` | Bulk-resolve merge conflicts by keeping `mine` or `theirs`, then stage |
|
|
85
96
|
|
|
86
97
|
## Images in documents
|
|
87
98
|
|
|
@@ -109,10 +120,69 @@ The tool copies the file into `.docs-data/projects/{project}/assets/` and return
|
|
|
109
120
|
- **URL tab** — paste any external image URL; the markdown link is inserted at the cursor position
|
|
110
121
|
- **Project images gallery** — click any previously uploaded image to re-insert it
|
|
111
122
|
|
|
123
|
+
## Syncing across machines with GitHub
|
|
124
|
+
|
|
125
|
+
You can share documentation between several machines by backing the data directory with a **private** GitHub repository. The approach is git-based: only the human-readable markdown, assets, and the shared project list are versioned. Derived state (embeddings) is regenerated locally, and version history stays local to each machine.
|
|
126
|
+
|
|
127
|
+
### What is and isn't synced
|
|
128
|
+
|
|
129
|
+
| Synced (versioned) | Local only (gitignored) |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `projects/**` (markdown + assets) | `embeddings.db` (regenerated after pull) |
|
|
132
|
+
| `config.json` (project list) | `history.db` (per-machine version history) |
|
|
133
|
+
| | `local-state.json` (active project) |
|
|
134
|
+
|
|
135
|
+
### Setup
|
|
136
|
+
|
|
137
|
+
The sync target is **administrator-controlled**: the repository, branch and token are read from environment variables and **cannot be set or changed by the agent**. The agent can only pull/push against whatever repo you configured.
|
|
138
|
+
|
|
139
|
+
1. Create an **empty private repo** on GitHub (e.g. `my-docs`).
|
|
140
|
+
2. Create a **fine-grained PAT** scoped to that single repo (Repository permissions → **Contents: Read and write**).
|
|
141
|
+
3. Add the sync environment variables to your MCP client config, per machine:
|
|
142
|
+
|
|
143
|
+
```jsonc
|
|
144
|
+
{
|
|
145
|
+
"mcpServers": {
|
|
146
|
+
"mcp-documentor": {
|
|
147
|
+
"command": "npx",
|
|
148
|
+
"args": ["-y", "@imenam/mcp-documentor"],
|
|
149
|
+
"env": {
|
|
150
|
+
"MCP_DOCUMENTOR_SYNC_REMOTE": "https://github.com/<you>/my-docs.git",
|
|
151
|
+
"MCP_DOCUMENTOR_SYNC_BRANCH": "main", // optional, defaults to "main"
|
|
152
|
+
"MCP_DOCUMENTOR_SYNC_TOKEN": "github_pat_..."
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The token is injected into the `origin` URL inside `.git/config` only (which git never commits) — it is never pushed.
|
|
160
|
+
|
|
161
|
+
### Daily workflow
|
|
162
|
+
|
|
163
|
+
- `sync_docs` — pull then push in one step (recommended).
|
|
164
|
+
- `pull_docs` / `push_docs` — granular control.
|
|
165
|
+
- `sync_status` — inspect ahead/behind, conflicts, and uncommitted changes.
|
|
166
|
+
|
|
167
|
+
By default the server also performs a **defensive pull at startup**: it only fast-forwards a clean working tree and never overwrites local changes (disable with `SYNC_PULL_ON_START=false`). On the first run, a machine with no local data simply clones the remote.
|
|
168
|
+
|
|
169
|
+
### Conflicts
|
|
170
|
+
|
|
171
|
+
Because docs are plain markdown, merge conflicts are readable. When `pull_docs` reports a conflict:
|
|
172
|
+
|
|
173
|
+
- edit the conflicted `.md` files directly (remove the `<<<<<<<` / `>>>>>>>` markers), **or**
|
|
174
|
+
- run `resolve_sync_conflict` with `strategy: "mine"` or `"theirs"` to take one side in bulk,
|
|
175
|
+
|
|
176
|
+
then run `push_docs` to publish the resolution. If you have **uncommitted** local changes while the remote has moved, the pull reports `diverged` without touching anything — push your changes first, then pull again.
|
|
177
|
+
|
|
178
|
+
> Read-only machines (`READ_ONLY=1`) can still `pull_docs` and `sync_status`, but `push_docs`, `sync_docs`, and `resolve_sync_conflict` are disabled.
|
|
179
|
+
|
|
112
180
|
## Data Storage
|
|
113
181
|
|
|
114
182
|
By default, data lives under **`.docs-data`** next to the resolved package root (the folder that contains `package.json` for the running install). Set **`MCP_DATA_DIR`** to use another directory. Contents include projects, SQLite/embeddings, and logs under `.docs-data/logs`. This tree is **not** part of the npm package; it is created at runtime.
|
|
115
183
|
|
|
184
|
+
The active project is a machine-local preference kept in `local-state.json` (gitignored), separate from the shared `config.json`. On first run, a legacy `currentProject` stored in `config.json` is migrated automatically. GitHub sync settings live in environment variables, not on disk.
|
|
185
|
+
|
|
116
186
|
## License
|
|
117
187
|
|
|
118
188
|
ISC
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** True quand la variable d'environnement READ_ONLY est activée. */
|
|
2
|
+
export function isReadOnly() {
|
|
3
|
+
const v = (process.env.READ_ONLY ?? "").trim().toLowerCase();
|
|
4
|
+
return v === "1" || v === "true" || v === "yes";
|
|
5
|
+
}
|
|
6
|
+
/** Outils MCP qui créent / modifient / suppriment de la donnée. */
|
|
7
|
+
export const WRITE_TOOLS = new Set([
|
|
8
|
+
"create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc",
|
|
9
|
+
"restore_doc_version", "refresh_file_snapshot", "reindex_embeddings",
|
|
10
|
+
"prepare_export_bundle", "delete_export_bundle", "upload_image",
|
|
11
|
+
// Sync: pull_docs and sync_status stay allowed (read-only consumer machines
|
|
12
|
+
// still pull updates); these mutate local/remote state and are blocked.
|
|
13
|
+
"push_docs", "sync_docs", "resolve_sync_conflict",
|
|
14
|
+
]);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { StorageEngine } from "./storage-engine.js";
|
|
2
|
+
export interface SyncStatus {
|
|
3
|
+
branch: string;
|
|
4
|
+
ahead: number;
|
|
5
|
+
behind: number;
|
|
6
|
+
dirty: string[];
|
|
7
|
+
conflicted: string[];
|
|
8
|
+
}
|
|
9
|
+
export type PullResult = {
|
|
10
|
+
status: "up-to-date";
|
|
11
|
+
} | {
|
|
12
|
+
status: "updated";
|
|
13
|
+
changed: string[];
|
|
14
|
+
} | {
|
|
15
|
+
status: "diverged";
|
|
16
|
+
ahead: number;
|
|
17
|
+
behind: number;
|
|
18
|
+
} | {
|
|
19
|
+
status: "conflict";
|
|
20
|
+
conflicted: string[];
|
|
21
|
+
};
|
|
22
|
+
export declare class SyncEngine {
|
|
23
|
+
private storage;
|
|
24
|
+
private git;
|
|
25
|
+
private dataDir;
|
|
26
|
+
constructor(storage: StorageEngine);
|
|
27
|
+
isConfigured(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Builds an authenticated HTTPS URL by injecting the PAT. The result is only
|
|
30
|
+
* ever stored in .git/config (which git never commits), never versioned.
|
|
31
|
+
*/
|
|
32
|
+
private authedUrl;
|
|
33
|
+
private requireConfig;
|
|
34
|
+
/**
|
|
35
|
+
* Ensures the data dir is a git repo with a .gitignore and an authenticated
|
|
36
|
+
* origin. The token is stored only in .git/config (which git never commits),
|
|
37
|
+
* so normal origin-based fetch/push work and upstream tracking — required for
|
|
38
|
+
* ahead/behind detection — stays accurate.
|
|
39
|
+
*/
|
|
40
|
+
ensureRepo(): Promise<void>;
|
|
41
|
+
status(): Promise<SyncStatus>;
|
|
42
|
+
/**
|
|
43
|
+
* Defensive pull: only fast-forwards a clean working tree. Never auto-merges
|
|
44
|
+
* a divergent history, so local data is never silently overwritten.
|
|
45
|
+
*/
|
|
46
|
+
pull(): Promise<PullResult>;
|
|
47
|
+
private diffNames;
|
|
48
|
+
/** Stages versioned files, commits if there is anything to commit, and pushes. */
|
|
49
|
+
push(message?: string): Promise<{
|
|
50
|
+
pushed: boolean;
|
|
51
|
+
committed: boolean;
|
|
52
|
+
message: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** Bulk-resolve conflicts by taking one side, then stage the resolved files. */
|
|
55
|
+
resolveConflict(strategy: "mine" | "theirs", paths?: string[]): Promise<string[]>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { simpleGit } from "simple-git";
|
|
4
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
5
|
+
const logger = createLogger("GitSync");
|
|
6
|
+
const GITIGNORE_CONTENT = [
|
|
7
|
+
"# Derived / machine-local state — never synced",
|
|
8
|
+
"embeddings.db",
|
|
9
|
+
"embeddings.db-shm",
|
|
10
|
+
"embeddings.db-wal",
|
|
11
|
+
"history.db",
|
|
12
|
+
"history.db-shm",
|
|
13
|
+
"history.db-wal",
|
|
14
|
+
"*.db-shm",
|
|
15
|
+
"*.db-wal",
|
|
16
|
+
"logs/",
|
|
17
|
+
"temps/",
|
|
18
|
+
"errors-tmp-files/",
|
|
19
|
+
"export-bundles.json",
|
|
20
|
+
"local-state.json",
|
|
21
|
+
"",
|
|
22
|
+
].join("\n");
|
|
23
|
+
export class SyncEngine {
|
|
24
|
+
storage;
|
|
25
|
+
git;
|
|
26
|
+
dataDir;
|
|
27
|
+
constructor(storage) {
|
|
28
|
+
this.storage = storage;
|
|
29
|
+
this.dataDir = storage.getDataDir();
|
|
30
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
31
|
+
this.git = simpleGit(this.dataDir);
|
|
32
|
+
}
|
|
33
|
+
isConfigured() {
|
|
34
|
+
const cfg = this.storage.getSyncConfig();
|
|
35
|
+
return !!cfg?.remoteUrl;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Builds an authenticated HTTPS URL by injecting the PAT. The result is only
|
|
39
|
+
* ever stored in .git/config (which git never commits), never versioned.
|
|
40
|
+
*/
|
|
41
|
+
authedUrl(cfg) {
|
|
42
|
+
const token = cfg.token;
|
|
43
|
+
if (!token)
|
|
44
|
+
return cfg.remoteUrl;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(cfg.remoteUrl);
|
|
47
|
+
if (u.protocol !== "https:")
|
|
48
|
+
return cfg.remoteUrl;
|
|
49
|
+
u.username = token;
|
|
50
|
+
u.password = "";
|
|
51
|
+
return u.toString();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return cfg.remoteUrl;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
requireConfig() {
|
|
58
|
+
const cfg = this.storage.getSyncConfig();
|
|
59
|
+
if (!cfg?.remoteUrl) {
|
|
60
|
+
throw new Error("Sync is not configured. Set MCP_DOCUMENTOR_SYNC_REMOTE (and MCP_DOCUMENTOR_SYNC_TOKEN) in the server environment.");
|
|
61
|
+
}
|
|
62
|
+
return cfg;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ensures the data dir is a git repo with a .gitignore and an authenticated
|
|
66
|
+
* origin. The token is stored only in .git/config (which git never commits),
|
|
67
|
+
* so normal origin-based fetch/push work and upstream tracking — required for
|
|
68
|
+
* ahead/behind detection — stays accurate.
|
|
69
|
+
*/
|
|
70
|
+
async ensureRepo() {
|
|
71
|
+
const cfg = this.requireConfig();
|
|
72
|
+
const authed = this.authedUrl(cfg);
|
|
73
|
+
const gitignorePath = path.join(this.dataDir, ".gitignore");
|
|
74
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
75
|
+
fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf-8");
|
|
76
|
+
}
|
|
77
|
+
if (!fs.existsSync(path.join(this.dataDir, ".git"))) {
|
|
78
|
+
await this.git.init();
|
|
79
|
+
logger.info(`Initialized git repo in ${this.dataDir}`);
|
|
80
|
+
}
|
|
81
|
+
// (Re)point origin at the authenticated URL.
|
|
82
|
+
const remotes = await this.git.getRemotes(true);
|
|
83
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
84
|
+
if (!origin) {
|
|
85
|
+
await this.git.addRemote("origin", authed);
|
|
86
|
+
}
|
|
87
|
+
else if (origin.refs.fetch !== authed) {
|
|
88
|
+
await this.git.remote(["set-url", "origin", authed]);
|
|
89
|
+
}
|
|
90
|
+
// Make sure we're on the configured branch.
|
|
91
|
+
const branches = await this.git.branchLocal();
|
|
92
|
+
if (branches.current !== cfg.branch) {
|
|
93
|
+
try {
|
|
94
|
+
await this.git.checkoutLocalBranch(cfg.branch);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
await this.git.checkout(cfg.branch);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async status() {
|
|
102
|
+
this.requireConfig();
|
|
103
|
+
const s = await this.git.status();
|
|
104
|
+
return {
|
|
105
|
+
branch: s.current ?? "",
|
|
106
|
+
ahead: s.ahead,
|
|
107
|
+
behind: s.behind,
|
|
108
|
+
dirty: s.files.map((f) => f.path),
|
|
109
|
+
conflicted: s.conflicted,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Defensive pull: only fast-forwards a clean working tree. Never auto-merges
|
|
114
|
+
* a divergent history, so local data is never silently overwritten.
|
|
115
|
+
*/
|
|
116
|
+
async pull() {
|
|
117
|
+
const cfg = this.requireConfig();
|
|
118
|
+
await this.ensureRepo();
|
|
119
|
+
await this.git.fetch("origin", cfg.branch);
|
|
120
|
+
const remoteRef = `refs/remotes/origin/${cfg.branch}`;
|
|
121
|
+
// No remote branch yet (fresh repo) — nothing to pull.
|
|
122
|
+
const hasRemote = await this.git
|
|
123
|
+
.raw(["rev-parse", "--verify", "--quiet", remoteRef])
|
|
124
|
+
.then(() => true)
|
|
125
|
+
.catch(() => false);
|
|
126
|
+
if (!hasRemote)
|
|
127
|
+
return { status: "up-to-date" };
|
|
128
|
+
// Fresh clone: the local branch has no commit yet, so status can't compute
|
|
129
|
+
// ahead/behind. Materialize the remote branch directly.
|
|
130
|
+
const hasLocalCommit = await this.git.revparse(["--verify", "HEAD"]).then(() => true).catch(() => false);
|
|
131
|
+
if (!hasLocalCommit) {
|
|
132
|
+
await this.git.raw(["reset", "--hard", `origin/${cfg.branch}`]);
|
|
133
|
+
await this.git.branch(["--set-upstream-to", `origin/${cfg.branch}`, cfg.branch]).catch(() => { });
|
|
134
|
+
const changed = (await this.git.raw(["ls-tree", "-r", "--name-only", "HEAD"])).split("\n").filter(Boolean);
|
|
135
|
+
return { status: "updated", changed };
|
|
136
|
+
}
|
|
137
|
+
// Ensure upstream tracking so status ahead/behind is meaningful.
|
|
138
|
+
await this.git.branch(["--set-upstream-to", `origin/${cfg.branch}`, cfg.branch]).catch(() => { });
|
|
139
|
+
const status = await this.git.status();
|
|
140
|
+
if (status.behind === 0)
|
|
141
|
+
return { status: "up-to-date" };
|
|
142
|
+
// Uncommitted local changes block a safe merge — never risk clobbering them.
|
|
143
|
+
// Surface as diverged so the user commits/pushes (or stashes) first.
|
|
144
|
+
if (status.files.length > 0) {
|
|
145
|
+
return { status: "diverged", ahead: status.ahead, behind: status.behind };
|
|
146
|
+
}
|
|
147
|
+
const before = await this.git.revparse(["HEAD"]).catch(() => "");
|
|
148
|
+
// Pure behind → fast-forward.
|
|
149
|
+
if (status.ahead === 0) {
|
|
150
|
+
await this.git.merge(["--ff-only", `origin/${cfg.branch}`]);
|
|
151
|
+
const after = await this.git.revparse(["HEAD"]).catch(() => "");
|
|
152
|
+
return { status: "updated", changed: await this.diffNames(before, after) };
|
|
153
|
+
}
|
|
154
|
+
// Diverged with a clean tree → attempt a real merge. A conflict stops with
|
|
155
|
+
// markers in place (both versions preserved) so it can be resolved + pushed.
|
|
156
|
+
try {
|
|
157
|
+
await this.git.merge([`origin/${cfg.branch}`]);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
const after = await this.git.status();
|
|
161
|
+
if (after.conflicted.length > 0)
|
|
162
|
+
return { status: "conflict", conflicted: after.conflicted };
|
|
163
|
+
throw new Error("Merge failed without recorded conflicts. Resolve manually in the data directory.");
|
|
164
|
+
}
|
|
165
|
+
const merged = await this.git.revparse(["HEAD"]).catch(() => "");
|
|
166
|
+
return { status: "updated", changed: await this.diffNames(before, merged) };
|
|
167
|
+
}
|
|
168
|
+
async diffNames(before, after) {
|
|
169
|
+
if (!before || !after || before === after)
|
|
170
|
+
return [];
|
|
171
|
+
return (await this.git.raw(["diff", "--name-only", before, after])).split("\n").filter(Boolean);
|
|
172
|
+
}
|
|
173
|
+
/** Stages versioned files, commits if there is anything to commit, and pushes. */
|
|
174
|
+
async push(message) {
|
|
175
|
+
const cfg = this.requireConfig();
|
|
176
|
+
await this.ensureRepo();
|
|
177
|
+
// .gitignore (written by ensureRepo) excludes all derived/local state,
|
|
178
|
+
// so -A safely stages only versioned files (projects/, config.json, .gitignore).
|
|
179
|
+
await this.git.add(["-A"]);
|
|
180
|
+
const status = await this.git.status();
|
|
181
|
+
let committed = false;
|
|
182
|
+
if (status.staged.length > 0 || status.created.length > 0 || status.deleted.length > 0 || status.modified.length > 0) {
|
|
183
|
+
const machine = process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown-machine";
|
|
184
|
+
const msg = message?.trim() || `docs sync from ${machine} · ${new Date().toISOString()}`;
|
|
185
|
+
await this.git.commit(msg);
|
|
186
|
+
committed = true;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
193
|
+
if (/fetch first|non-fast-forward|\[rejected\]/i.test(msg)) {
|
|
194
|
+
throw new Error("Push rejected: the remote has commits you don't have locally. Run pull_docs to integrate them, then push again.");
|
|
195
|
+
}
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
return { pushed: true, committed, message: committed ? "Committed and pushed." : "Nothing new to commit; pushed existing commits." };
|
|
199
|
+
}
|
|
200
|
+
/** Bulk-resolve conflicts by taking one side, then stage the resolved files. */
|
|
201
|
+
async resolveConflict(strategy, paths) {
|
|
202
|
+
this.requireConfig();
|
|
203
|
+
const status = await this.git.status();
|
|
204
|
+
const targets = paths && paths.length > 0 ? paths : status.conflicted;
|
|
205
|
+
if (targets.length === 0)
|
|
206
|
+
return [];
|
|
207
|
+
// During a merge, "ours" = current branch (mine), "theirs" = incoming.
|
|
208
|
+
const sideFlag = strategy === "mine" ? "--ours" : "--theirs";
|
|
209
|
+
for (const p of targets) {
|
|
210
|
+
await this.git.raw(["checkout", sideFlag, "--", p]);
|
|
211
|
+
await this.git.add([p]);
|
|
212
|
+
}
|
|
213
|
+
return targets;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -5,6 +5,17 @@ interface GlobalConfig {
|
|
|
5
5
|
projects: string[];
|
|
6
6
|
foldersMigrated?: boolean;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* GitHub sync settings. Sourced exclusively from environment variables — the
|
|
10
|
+
* repository, branch and token are administration concerns and must never be
|
|
11
|
+
* settable by the agent at runtime.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncConfig {
|
|
14
|
+
remoteUrl: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
/** PAT for HTTPS auth (MCP_DOCUMENTOR_SYNC_TOKEN). */
|
|
17
|
+
token?: string;
|
|
18
|
+
}
|
|
8
19
|
export declare class StorageEngine {
|
|
9
20
|
private rootDir;
|
|
10
21
|
private historyStore?;
|
|
@@ -13,8 +24,19 @@ export declare class StorageEngine {
|
|
|
13
24
|
getDataDir(): string;
|
|
14
25
|
getProjectDir(project?: string): string;
|
|
15
26
|
private getConfigPath;
|
|
27
|
+
private getLocalStatePath;
|
|
28
|
+
private readLocalState;
|
|
29
|
+
private writeLocalState;
|
|
30
|
+
/**
|
|
31
|
+
* Returns the merged config. The shared part (projects list, migration flags)
|
|
32
|
+
* comes from config.json (versioned), while currentProject is a machine-local
|
|
33
|
+
* preference sourced from local-state.json (gitignored). Legacy config.json
|
|
34
|
+
* files that still embed currentProject are migrated transparently.
|
|
35
|
+
*/
|
|
16
36
|
getConfig(): GlobalConfig;
|
|
17
37
|
saveConfig(config: GlobalConfig): void;
|
|
38
|
+
/** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
|
|
39
|
+
getSyncConfig(): SyncConfig | undefined;
|
|
18
40
|
ensureProject(project: string): void;
|
|
19
41
|
private walkMdFiles;
|
|
20
42
|
private docIdToFilePath;
|
|
@@ -27,22 +27,81 @@ export class StorageEngine {
|
|
|
27
27
|
getConfigPath() {
|
|
28
28
|
return path.join(this.getDataDir(), "config.json");
|
|
29
29
|
}
|
|
30
|
+
getLocalStatePath() {
|
|
31
|
+
return path.join(this.getDataDir(), "local-state.json");
|
|
32
|
+
}
|
|
33
|
+
readLocalState() {
|
|
34
|
+
const p = this.getLocalStatePath();
|
|
35
|
+
if (fs.existsSync(p)) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
logger.warn("Failed to parse local-state.json, using defaults");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
writeLocalState(state) {
|
|
46
|
+
const p = this.getLocalStatePath();
|
|
47
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
48
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the merged config. The shared part (projects list, migration flags)
|
|
52
|
+
* comes from config.json (versioned), while currentProject is a machine-local
|
|
53
|
+
* preference sourced from local-state.json (gitignored). Legacy config.json
|
|
54
|
+
* files that still embed currentProject are migrated transparently.
|
|
55
|
+
*/
|
|
30
56
|
getConfig() {
|
|
31
57
|
const configPath = this.getConfigPath();
|
|
58
|
+
let shared = {};
|
|
32
59
|
if (fs.existsSync(configPath)) {
|
|
33
60
|
try {
|
|
34
|
-
|
|
61
|
+
shared = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
35
62
|
}
|
|
36
63
|
catch {
|
|
37
64
|
logger.warn("Failed to parse config.json, using defaults");
|
|
38
65
|
}
|
|
39
66
|
}
|
|
40
|
-
|
|
67
|
+
const local = this.readLocalState();
|
|
68
|
+
const projects = shared.projects ?? ["default"];
|
|
69
|
+
// Migrate a legacy currentProject embedded in config.json into local-state.
|
|
70
|
+
if (shared.currentProject !== undefined && local.currentProject === undefined) {
|
|
71
|
+
local.currentProject = shared.currentProject;
|
|
72
|
+
this.writeLocalState(local);
|
|
73
|
+
delete shared.currentProject;
|
|
74
|
+
fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
|
|
75
|
+
logger.info("Migrated currentProject from config.json to local-state.json");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
currentProject: local.currentProject ?? projects[0] ?? "default",
|
|
79
|
+
projects,
|
|
80
|
+
foldersMigrated: shared.foldersMigrated,
|
|
81
|
+
};
|
|
41
82
|
}
|
|
42
83
|
saveConfig(config) {
|
|
84
|
+
// Shared, versioned part → config.json (currentProject is intentionally excluded).
|
|
43
85
|
const configPath = this.getConfigPath();
|
|
86
|
+
const shared = {
|
|
87
|
+
projects: config.projects,
|
|
88
|
+
foldersMigrated: config.foldersMigrated,
|
|
89
|
+
};
|
|
44
90
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
45
|
-
fs.writeFileSync(configPath, JSON.stringify(
|
|
91
|
+
fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
|
|
92
|
+
// Machine-local part → local-state.json.
|
|
93
|
+
const local = this.readLocalState();
|
|
94
|
+
local.currentProject = config.currentProject;
|
|
95
|
+
this.writeLocalState(local);
|
|
96
|
+
}
|
|
97
|
+
/** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
|
|
98
|
+
getSyncConfig() {
|
|
99
|
+
const remoteUrl = process.env.MCP_DOCUMENTOR_SYNC_REMOTE?.trim();
|
|
100
|
+
if (!remoteUrl)
|
|
101
|
+
return undefined;
|
|
102
|
+
const branch = process.env.MCP_DOCUMENTOR_SYNC_BRANCH?.trim() || "main";
|
|
103
|
+
const token = process.env.MCP_DOCUMENTOR_SYNC_TOKEN?.trim() || undefined;
|
|
104
|
+
return { remoteUrl, branch, token };
|
|
46
105
|
}
|
|
47
106
|
ensureProject(project) {
|
|
48
107
|
const projectDir = path.join(this.getDataDir(), "projects", project);
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Hono } from "hono";
|
|
|
4
4
|
import { streamSSE } from "hono/streaming";
|
|
5
5
|
import { ProxyClient } from "@imenam/mcp-gui-interface";
|
|
6
6
|
import { IPCMessageSchema } from "../shared/schemas/doc.schema.js";
|
|
7
|
+
import { isReadOnly } from "../shared/read-only.js";
|
|
7
8
|
import path from "path";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import { execFile } from "child_process";
|
|
@@ -184,6 +185,23 @@ function setupIpcErrorHandlers() {
|
|
|
184
185
|
});
|
|
185
186
|
}
|
|
186
187
|
// --- HTTP Routes ---
|
|
188
|
+
// Bloque les écritures GUI en mode lecture seule (READ_ONLY).
|
|
189
|
+
const READ_ONLY_WRITE_ROUTES = [
|
|
190
|
+
{ method: "POST", prefix: "/api/doc" },
|
|
191
|
+
{ method: "PUT", prefix: "/api/doc/" },
|
|
192
|
+
{ method: "POST", prefix: "/api/images" },
|
|
193
|
+
{ method: "POST", prefix: "/api/configurations/apply" },
|
|
194
|
+
];
|
|
195
|
+
app.use("*", async (c, next) => {
|
|
196
|
+
if (isReadOnly()) {
|
|
197
|
+
const m = c.req.method;
|
|
198
|
+
const p = c.req.path;
|
|
199
|
+
if (READ_ONLY_WRITE_ROUTES.some((r) => r.method === m && p.startsWith(r.prefix))) {
|
|
200
|
+
return c.json({ error: "READ_ONLY mode: write operations are disabled" }, 403);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return next();
|
|
204
|
+
});
|
|
187
205
|
app.get("/proxy/health", (c) => {
|
|
188
206
|
const memory = process.memoryUsage();
|
|
189
207
|
return c.json({
|