@imenam/mcp-documentor 1.0.56 → 1.0.58

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 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
 
@@ -59,6 +60,10 @@ Add to your MCP client config (e.g. Cursor `mcp.json`):
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) |
61
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 |
62
67
 
63
68
  Optional `.env` at the package root (directory containing `package.json` discovered from the installed binary) is loaded when present.
64
69
 
@@ -83,6 +88,11 @@ Optional `.env` at the package root (directory containing `package.json` discove
83
88
  | `list_projects` | List all documentation projects |
84
89
  | `set_project` | Switch the active project **for the GUI only** (not for MCP tools) |
85
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 |
86
96
 
87
97
  ## Images in documents
88
98
 
@@ -110,10 +120,69 @@ The tool copies the file into `.docs-data/projects/{project}/assets/` and return
110
120
  - **URL tab** — paste any external image URL; the markdown link is inserted at the cursor position
111
121
  - **Project images gallery** — click any previously uploaded image to re-insert it
112
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
+
113
180
  ## Data Storage
114
181
 
115
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.
116
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
+
117
186
  ## License
118
187
 
119
188
  ISC
@@ -8,4 +8,7 @@ export const WRITE_TOOLS = new Set([
8
8
  "create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc",
9
9
  "restore_doc_version", "refresh_file_snapshot", "reindex_embeddings",
10
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",
11
14
  ]);
@@ -80,6 +80,7 @@ export declare const FreshnessReportSchema: z.ZodObject<{
80
80
  up_to_date: "up_to_date";
81
81
  stale: "stale";
82
82
  no_tracking: "no_tracking";
83
+ unverifiable: "unverifiable";
83
84
  }>;
84
85
  modified: z.ZodArray<z.ZodString>;
85
86
  missing: z.ZodArray<z.ZodString>;
@@ -36,7 +36,7 @@ export const DocSchema = z.object({
36
36
  updatedAt: z.string(),
37
37
  });
38
38
  export const FreshnessReportSchema = z.object({
39
- status: z.enum(["up_to_date", "stale", "no_tracking"]),
39
+ status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable"]),
40
40
  modified: z.array(z.string()),
41
41
  missing: z.array(z.string()),
42
42
  unchanged: z.array(z.string()),
@@ -47,7 +47,7 @@ export const CreateDocArgsSchema = z.object({
47
47
  title: z.string().describe("Title of the documentation entry"),
48
48
  content: z.string().optional().describe("Documentation content in markdown"),
49
49
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
50
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
50
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
51
51
  tags: z.array(z.string()).optional().describe("Tags for categorization"),
52
52
  folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
53
53
  related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
@@ -59,7 +59,7 @@ export const UpdateDocArgsSchema = z.object({
59
59
  title: z.string().optional().describe("Title of the documentation entry"),
60
60
  content: z.string().optional().describe("New content (replaces existing)"),
61
61
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
62
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
62
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
63
63
  tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
64
64
  related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
65
65
  });
@@ -80,7 +80,7 @@ export const SearchDocsArgsSchema = z.object({
80
80
  });
81
81
  export const RefreshFileSnapshotArgsSchema = z.object({
82
82
  paths: z.array(z.string()).min(1).describe("Paths of documentation entries to refresh, e.g. ['backend/auth/authentication', 'setup']"),
83
- file_paths: z.array(z.string()).optional().describe("Specific tracked file paths to refresh, as absolute paths (refreshes all tracked files if omitted)"),
83
+ file_paths: z.array(z.string()).optional().describe("Specific tracked files to refresh, as absolute paths (they are matched against the stored paths, relative or absolute). Refreshes all tracked files if omitted."),
84
84
  });
85
85
  export const GetDocMetadataArgsSchema = z.object({
86
86
  path: z.string().describe("Path of the documentation entry, e.g. 'backend/auth/authentication'"),
@@ -101,7 +101,7 @@ export const CheckLinksArgsSchema = z.object({
101
101
  });
102
102
  export const GitDiffDocArgsSchema = z.object({
103
103
  paths: z.array(z.string()).min(1).describe("Paths of documentation entries to diff, e.g. ['backend/auth/authentication']"),
104
- file_paths: z.array(z.string()).optional().describe("Specific tracked file paths to diff (all tracked files if omitted)"),
104
+ file_paths: z.array(z.string()).optional().describe("Specific tracked files to diff, as absolute paths (matched against the stored paths, relative or absolute). All tracked files if omitted."),
105
105
  });
106
106
  export const SetProjectArgsSchema = z.object({
107
107
  project: z.string().describe("Project name to switch to (created if it does not exist)"),
@@ -4,11 +4,11 @@ export interface CodeRefResult {
4
4
  additionalTrackedFiles: TrackedFileInput[];
5
5
  }
6
6
  export declare function resolveCodeRefs(content: string): CodeRefResult;
7
- export declare function snapshotTrackedFiles(inputs: TrackedFileInput[]): TrackedFile[];
7
+ export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): TrackedFile[];
8
8
  /**
9
9
  * Merges snapshotted tracked files into an existing list.
10
10
  * - If a file is not yet tracked: appended.
11
11
  * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
12
12
  * - Otherwise (whole-file tracking involved): the existing entry is replaced.
13
13
  */
14
- export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[]): TrackedFile[];
14
+ export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): TrackedFile[];
@@ -2,6 +2,7 @@ import crypto from "crypto";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { snapshotSection } from "./section-tracker.js";
5
+ import { toStoredPath } from "./path-resolver.js";
5
6
  const EXT_TO_LANG = {
6
7
  ts: "typescript",
7
8
  tsx: "tsx",
@@ -46,18 +47,21 @@ export function resolveCodeRefs(content) {
46
47
  });
47
48
  return { content: resolved, additionalTrackedFiles };
48
49
  }
49
- export function snapshotTrackedFiles(inputs) {
50
+ export function snapshotTrackedFiles(inputs, project) {
51
+ // Keyed on the *stored* (portable) path so two inputs pointing at the same
52
+ // file dedupe regardless of how the agent spelled the path.
50
53
  const byPath = new Map();
51
54
  for (const input of inputs) {
52
55
  const fp = typeof input === "string" ? input : input.path;
53
56
  const absolutePath = path.resolve(process.cwd(), fp);
57
+ const storedPath = toStoredPath(fp, project);
54
58
  const mtime = fs.existsSync(absolutePath)
55
59
  ? fs.statSync(absolutePath).mtime.toISOString()
56
60
  : new Date().toISOString();
57
61
  const isWhole = typeof input === "string" || input.lines.length === 0;
58
- const existing = byPath.get(absolutePath);
62
+ const existing = byPath.get(storedPath);
59
63
  if (isWhole) {
60
- byPath.set(absolutePath, { mtime, lineRanges: [], wholeFile: true });
64
+ byPath.set(storedPath, { absolutePath, mtime, lineRanges: [], wholeFile: true });
61
65
  }
62
66
  else if (existing) {
63
67
  if (!existing.wholeFile) {
@@ -65,7 +69,8 @@ export function snapshotTrackedFiles(inputs) {
65
69
  }
66
70
  }
67
71
  else {
68
- byPath.set(absolutePath, {
72
+ byPath.set(storedPath, {
73
+ absolutePath,
69
74
  mtime,
70
75
  lineRanges: [...input.lines],
71
76
  wholeFile: false,
@@ -73,16 +78,16 @@ export function snapshotTrackedFiles(inputs) {
73
78
  }
74
79
  }
75
80
  const tracked = [];
76
- for (const [absolutePath, { mtime, lineRanges, wholeFile }] of byPath) {
81
+ for (const [storedPath, { absolutePath, mtime, lineRanges, wholeFile }] of byPath) {
77
82
  if (!wholeFile && lineRanges.length > 0 && fs.existsSync(absolutePath)) {
78
83
  const sections = lineRanges
79
84
  .filter(({ start, end }) => start <= end)
80
85
  .map(({ start, end }) => snapshotSection(absolutePath, start, end));
81
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
86
+ tracked.push({ path: storedPath, lastModifiedAt: mtime, sections });
82
87
  }
83
88
  else {
84
89
  const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
85
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
90
+ tracked.push({ path: storedPath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
86
91
  }
87
92
  }
88
93
  return tracked;
@@ -93,10 +98,10 @@ export function snapshotTrackedFiles(inputs) {
93
98
  * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
94
99
  * - Otherwise (whole-file tracking involved): the existing entry is replaced.
95
100
  */
96
- export function mergeTrackedFiles(existing, additions) {
101
+ export function mergeTrackedFiles(existing, additions, project) {
97
102
  if (additions.length === 0)
98
103
  return existing;
99
- const snapshotted = snapshotTrackedFiles(additions);
104
+ const snapshotted = snapshotTrackedFiles(additions, project);
100
105
  const result = [...existing];
101
106
  for (const newTf of snapshotted) {
102
107
  const idx = result.findIndex(tf => tf.path === newTf.path);
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ /** Test-only: clears the memoised env snapshot so a changed env is re-read. */
2
+ export declare function resetCodeRootsCache(): void;
3
+ /** The configured code root for a project, or undefined if none applies. */
4
+ export declare function getCodeRoot(project: string): string | undefined;
5
+ /**
6
+ * Converts an input path (typically the absolute path supplied by an agent)
7
+ * into the portable form persisted in a doc.
8
+ *
9
+ * Falls back to an absolute path — with a warning — when there is no code root,
10
+ * or when the file lives outside the root (including a different Windows drive).
11
+ */
12
+ export declare function toStoredPath(inputPath: string, project: string): string;
13
+ /**
14
+ * Resolves a stored path to an absolute filesystem path on this machine.
15
+ *
16
+ * - absolute stored path (legacy / fallback) → returned as-is
17
+ * - relative stored path + configured root → joined with the root
18
+ * - relative stored path + no root configured → undefined (unverifiable here)
19
+ */
20
+ export declare function toAbsolutePath(stored: string, project: string): string | undefined;
@@ -0,0 +1,82 @@
1
+ import path from "path";
2
+ import { createLogger } from "@imenam/mcp-gui-interface";
3
+ const logger = createLogger("PathResolver");
4
+ let cached;
5
+ function loadCodeRoots() {
6
+ if (cached)
7
+ return cached;
8
+ const defaultRoot = process.env.MCP_DOCUMENTOR_CODE_ROOT?.trim() || undefined;
9
+ let byProject = {};
10
+ const rawMap = process.env.MCP_DOCUMENTOR_CODE_ROOTS?.trim();
11
+ if (rawMap) {
12
+ try {
13
+ const parsed = JSON.parse(rawMap);
14
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
15
+ for (const [proj, root] of Object.entries(parsed)) {
16
+ if (typeof root === "string" && root.trim() !== "") {
17
+ byProject[proj] = root.trim();
18
+ }
19
+ }
20
+ }
21
+ else {
22
+ logger.warn("MCP_DOCUMENTOR_CODE_ROOTS is not a JSON object, ignoring it");
23
+ }
24
+ }
25
+ catch {
26
+ logger.warn("Failed to parse MCP_DOCUMENTOR_CODE_ROOTS as JSON, ignoring it");
27
+ }
28
+ }
29
+ cached = { default: defaultRoot, byProject };
30
+ return cached;
31
+ }
32
+ /** Test-only: clears the memoised env snapshot so a changed env is re-read. */
33
+ export function resetCodeRootsCache() {
34
+ cached = undefined;
35
+ }
36
+ /** The configured code root for a project, or undefined if none applies. */
37
+ export function getCodeRoot(project) {
38
+ const roots = loadCodeRoots();
39
+ return roots.byProject[project] ?? roots.default;
40
+ }
41
+ function toForwardSlashes(p) {
42
+ return p.split(path.sep).join("/");
43
+ }
44
+ /**
45
+ * Converts an input path (typically the absolute path supplied by an agent)
46
+ * into the portable form persisted in a doc.
47
+ *
48
+ * Falls back to an absolute path — with a warning — when there is no code root,
49
+ * or when the file lives outside the root (including a different Windows drive).
50
+ */
51
+ export function toStoredPath(inputPath, project) {
52
+ const abs = path.resolve(process.cwd(), inputPath);
53
+ const root = getCodeRoot(project);
54
+ if (!root) {
55
+ return toForwardSlashes(abs);
56
+ }
57
+ const rel = path.relative(path.resolve(root), abs);
58
+ // `rel` escaping the root ("../…") or being absolute (different drive on
59
+ // Windows) means the file is not under the root — keep it absolute.
60
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
61
+ logger.warn(`tracked file "${abs}" is outside the code root for project "${project}" (${root}); storing as an absolute path`);
62
+ return toForwardSlashes(abs);
63
+ }
64
+ return toForwardSlashes(rel);
65
+ }
66
+ /**
67
+ * Resolves a stored path to an absolute filesystem path on this machine.
68
+ *
69
+ * - absolute stored path (legacy / fallback) → returned as-is
70
+ * - relative stored path + configured root → joined with the root
71
+ * - relative stored path + no root configured → undefined (unverifiable here)
72
+ */
73
+ export function toAbsolutePath(stored, project) {
74
+ if (path.isAbsolute(stored)) {
75
+ return stored;
76
+ }
77
+ const root = getCodeRoot(project);
78
+ if (!root) {
79
+ return undefined;
80
+ }
81
+ return path.resolve(root, stored);
82
+ }