@imenam/mcp-documentor 1.0.100 → 1.0.102
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 +6 -0
- package/dist/src/core/embedding-store.js +12 -12
- package/dist/src/core/history-store.js +14 -14
- package/dist/src/gui-worker.js +1 -1
- package/gui/README.md +75 -0
- package/package.json +72 -69
- package/scripts/migrate-links.js +258 -258
- package/scripts/migrate-paths-to-relative.js +196 -196
package/README.md
CHANGED
|
@@ -362,6 +362,12 @@ By default, data lives under **`.docs-data`** next to the resolved package root
|
|
|
362
362
|
|
|
363
363
|
There is no server-side "active project": every MCP tool call names its project, and the GUI's selector only changes what that browser displays (kept in the URL and in `localStorage`). The open and closed folders of each project's navigation tree are also kept in `localStorage`, so a reload restores them. A legacy `currentProject` left in `config.json` is dropped on first read. GitHub sync settings live in environment variables, not on disk.
|
|
364
364
|
|
|
365
|
+
## Publication
|
|
366
|
+
|
|
367
|
+
Chaque push sur `master`, merge de pull request compris, publie une nouvelle version de `@imenam/mcp-documentor` sur npm (`.github/workflows/publish.yml`) : `npm ci` et build, puis `npm version patch`, dont le commit et le tag sont poussés sur `master`, et `npm publish`. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance. Un échec du build arrête le workflow avant la montée de version.
|
|
368
|
+
|
|
369
|
+
Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-documentor`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
|
|
370
|
+
|
|
365
371
|
## License
|
|
366
372
|
|
|
367
373
|
ISC
|
|
@@ -22,18 +22,18 @@ export class EmbeddingStore {
|
|
|
22
22
|
this.db = new Database(path.join(dataDir, DB_FILENAME));
|
|
23
23
|
this.db.pragma("journal_mode = WAL");
|
|
24
24
|
this.db.pragma("synchronous = NORMAL");
|
|
25
|
-
this.db.exec(`
|
|
26
|
-
CREATE TABLE IF NOT EXISTS embeddings (
|
|
27
|
-
project TEXT NOT NULL,
|
|
28
|
-
doc_id TEXT NOT NULL,
|
|
29
|
-
embedding BLOB NOT NULL,
|
|
30
|
-
PRIMARY KEY (project, doc_id)
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
CREATE TABLE IF NOT EXISTS meta (
|
|
34
|
-
key TEXT PRIMARY KEY,
|
|
35
|
-
value TEXT NOT NULL
|
|
36
|
-
);
|
|
25
|
+
this.db.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
27
|
+
project TEXT NOT NULL,
|
|
28
|
+
doc_id TEXT NOT NULL,
|
|
29
|
+
embedding BLOB NOT NULL,
|
|
30
|
+
PRIMARY KEY (project, doc_id)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
value TEXT NOT NULL
|
|
36
|
+
);
|
|
37
37
|
`);
|
|
38
38
|
this.handleModelDrift(model);
|
|
39
39
|
const { c } = this.db.prepare("SELECT COUNT(*) as c FROM embeddings").get();
|
|
@@ -15,20 +15,20 @@ export class HistoryStore {
|
|
|
15
15
|
this.db = new Database(path.join(dataDir, DB_FILENAME));
|
|
16
16
|
this.db.pragma("journal_mode = WAL");
|
|
17
17
|
this.db.pragma("synchronous = NORMAL");
|
|
18
|
-
this.db.exec(`
|
|
19
|
-
CREATE TABLE IF NOT EXISTS doc_history (
|
|
20
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
-
project TEXT NOT NULL,
|
|
22
|
-
doc_id TEXT NOT NULL,
|
|
23
|
-
version INTEGER NOT NULL,
|
|
24
|
-
content TEXT NOT NULL,
|
|
25
|
-
metadata TEXT NOT NULL,
|
|
26
|
-
changed_at TEXT NOT NULL,
|
|
27
|
-
change_type TEXT NOT NULL
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
CREATE INDEX IF NOT EXISTS idx_doc_history_lookup
|
|
31
|
-
ON doc_history(project, doc_id, version DESC);
|
|
18
|
+
this.db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS doc_history (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
project TEXT NOT NULL,
|
|
22
|
+
doc_id TEXT NOT NULL,
|
|
23
|
+
version INTEGER NOT NULL,
|
|
24
|
+
content TEXT NOT NULL,
|
|
25
|
+
metadata TEXT NOT NULL,
|
|
26
|
+
changed_at TEXT NOT NULL,
|
|
27
|
+
change_type TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_doc_history_lookup
|
|
31
|
+
ON doc_history(project, doc_id, version DESC);
|
|
32
32
|
`);
|
|
33
33
|
logger.info("HistoryStore initialized");
|
|
34
34
|
}
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -906,7 +906,7 @@ app.get("/api/export-zip/:id", async (c) => {
|
|
|
906
906
|
docId = ref;
|
|
907
907
|
}
|
|
908
908
|
else {
|
|
909
|
-
zip.file(`${ref}.md`, `# [Unqualified doc reference: ${ref} — no project given]
|
|
909
|
+
zip.file(`${ref}.md`, `# [Unqualified doc reference: ${ref} — no project given]
|
|
910
910
|
`);
|
|
911
911
|
continue;
|
|
912
912
|
}
|
package/gui/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# React + TypeScript + Vite
|
|
2
|
+
|
|
3
|
+
This app is the browser UI for MCP Documentor. Shared MCP/proxy helpers live in the Node package `@imenam/mcp-gui-interface` (used from the repo root server and `gui-worker`, not from this Vite bundle).
|
|
4
|
+
|
|
5
|
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
6
|
+
|
|
7
|
+
Currently, two official plugins are available:
|
|
8
|
+
|
|
9
|
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
10
|
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
11
|
+
|
|
12
|
+
## React Compiler
|
|
13
|
+
|
|
14
|
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
15
|
+
|
|
16
|
+
## Expanding the ESLint configuration
|
|
17
|
+
|
|
18
|
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
export default defineConfig([
|
|
22
|
+
globalIgnores(['dist']),
|
|
23
|
+
{
|
|
24
|
+
files: ['**/*.{ts,tsx}'],
|
|
25
|
+
extends: [
|
|
26
|
+
// Other configs...
|
|
27
|
+
|
|
28
|
+
// Remove tseslint.configs.recommended and replace with this
|
|
29
|
+
tseslint.configs.recommendedTypeChecked,
|
|
30
|
+
// Alternatively, use this for stricter rules
|
|
31
|
+
tseslint.configs.strictTypeChecked,
|
|
32
|
+
// Optionally, add this for stylistic rules
|
|
33
|
+
tseslint.configs.stylisticTypeChecked,
|
|
34
|
+
|
|
35
|
+
// Other configs...
|
|
36
|
+
],
|
|
37
|
+
languageOptions: {
|
|
38
|
+
parserOptions: {
|
|
39
|
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
40
|
+
tsconfigRootDir: import.meta.dirname,
|
|
41
|
+
},
|
|
42
|
+
// other options...
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// eslint.config.js
|
|
52
|
+
import reactX from 'eslint-plugin-react-x'
|
|
53
|
+
import reactDom from 'eslint-plugin-react-dom'
|
|
54
|
+
|
|
55
|
+
export default defineConfig([
|
|
56
|
+
globalIgnores(['dist']),
|
|
57
|
+
{
|
|
58
|
+
files: ['**/*.{ts,tsx}'],
|
|
59
|
+
extends: [
|
|
60
|
+
// Other configs...
|
|
61
|
+
// Enable lint rules for React
|
|
62
|
+
reactX.configs['recommended-typescript'],
|
|
63
|
+
// Enable lint rules for React DOM
|
|
64
|
+
reactDom.configs.recommended,
|
|
65
|
+
],
|
|
66
|
+
languageOptions: {
|
|
67
|
+
parserOptions: {
|
|
68
|
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
69
|
+
tsconfigRootDir: import.meta.dirname,
|
|
70
|
+
},
|
|
71
|
+
// other options...
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
])
|
|
75
|
+
```
|
package/package.json
CHANGED
|
@@ -1,69 +1,72 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@imenam/mcp-documentor",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"main": "dist/src/index.js",
|
|
5
|
-
"scripts": {
|
|
6
|
-
"test": "echo \"Error: no test specified\" && exit 1",
|
|
7
|
-
"build:backend": "tsc",
|
|
8
|
-
"build:gui": "cd gui && npm install && npm run build",
|
|
9
|
-
"build": "npm run build:backend && npm run build:gui",
|
|
10
|
-
"start": "node dist/src/index.js",
|
|
11
|
-
"prepublishOnly": "npm run build"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"@
|
|
27
|
-
"@
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"mdast-util-
|
|
37
|
-
"mdast-util-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
"mcp-documentor": "
|
|
48
|
-
"mcp-documentor-migrate": "scripts/migrate-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"dist",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"@types/
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@imenam/mcp-documentor",
|
|
3
|
+
"version": "1.0.102",
|
|
4
|
+
"main": "dist/src/index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
7
|
+
"build:backend": "tsc",
|
|
8
|
+
"build:gui": "cd gui && npm install && npm run build",
|
|
9
|
+
"build": "npm run build:backend && npm run build:gui",
|
|
10
|
+
"start": "node dist/src/index.js",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"documentation",
|
|
16
|
+
"markdown",
|
|
17
|
+
"semantic-search",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"ai"
|
|
20
|
+
],
|
|
21
|
+
"author": "Imena",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"description": "MCP server to create, manage and search project documentation with staleness tracking and semantic search.",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@hono/node-server": "^2.1.6",
|
|
26
|
+
"@imenam/mcp-gui-interface": "^1.0.14",
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
28
|
+
"better-sqlite3": "^12.8.0",
|
|
29
|
+
"diff": "^9.0.0",
|
|
30
|
+
"dotenv": "^17.3.1",
|
|
31
|
+
"github-slugger": "^2.0.0",
|
|
32
|
+
"gray-matter": "^4.0.3",
|
|
33
|
+
"hono": "^4.12.5",
|
|
34
|
+
"jszip": "^3.10.1",
|
|
35
|
+
"mdast-util-from-markdown": "^2.1.0",
|
|
36
|
+
"mdast-util-gfm": "^3.1.0",
|
|
37
|
+
"mdast-util-to-string": "^4.0.0",
|
|
38
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
39
|
+
"openai": "^6.25.0",
|
|
40
|
+
"puppeteer-core": "^25.0.4",
|
|
41
|
+
"simple-git": "^4.0.2",
|
|
42
|
+
"zod": "^4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"type": "module",
|
|
45
|
+
"bin": {
|
|
46
|
+
"mcp-documentor": "dist/src/index.js",
|
|
47
|
+
"mcp-documentor-migrate": "scripts/migrate-links.js",
|
|
48
|
+
"mcp-documentor-migrate-paths": "scripts/migrate-paths-to-relative.js"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"gui/dist",
|
|
53
|
+
"README.md",
|
|
54
|
+
"scripts"
|
|
55
|
+
],
|
|
56
|
+
"config": {
|
|
57
|
+
"FIXED_GUI_PORT": "4205",
|
|
58
|
+
"PROXY_URL": ""
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
62
|
+
"@types/node": "^22.0.0",
|
|
63
|
+
"typescript": "^5.0.0"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"repository": {
|
|
69
|
+
"type": "git",
|
|
70
|
+
"url": "git+https://github.com/Matthieu-Pesnot-Pin/mcp-documentor.git"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/scripts/migrate-links.js
CHANGED
|
@@ -1,258 +1,258 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* migrate-links.js
|
|
4
|
-
*
|
|
5
|
-
* Migrates inter-document references from the old slug-only format
|
|
6
|
-
* to the new full-path format (folder/name) introduced in Task 10.
|
|
7
|
-
*
|
|
8
|
-
* Fixes two types of links:
|
|
9
|
-
* - related_docs: ["old-slug"] → ["folder/old-slug"]
|
|
10
|
-
* - inline links: (doc://old-slug) → (doc://folder/old-slug)
|
|
11
|
-
* (doc://old-slug#h) → (doc://folder/old-slug#h)
|
|
12
|
-
*
|
|
13
|
-
* Usage:
|
|
14
|
-
* node scripts/migrate-links.js [--dry-run] [--data-dir /path/to/.docs-data]
|
|
15
|
-
*
|
|
16
|
-
* Options:
|
|
17
|
-
* --dry-run Show what would be changed without modifying any file.
|
|
18
|
-
* --data-dir Override the data directory (default: <project-root>/.docs-data).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import fs from "fs";
|
|
22
|
-
import path from "path";
|
|
23
|
-
import { fileURLToPath } from "url";
|
|
24
|
-
import matter from "gray-matter";
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Setup
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
-
const rootDir = path.join(__dirname, "..");
|
|
32
|
-
|
|
33
|
-
const args = process.argv.slice(2);
|
|
34
|
-
const dryRun = args.includes("--dry-run");
|
|
35
|
-
|
|
36
|
-
const dataDirArgIdx = args.indexOf("--data-dir");
|
|
37
|
-
const dataDir =
|
|
38
|
-
dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
|
|
39
|
-
? args[dataDirArgIdx + 1]
|
|
40
|
-
: path.join(rootDir, ".docs-data");
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Helpers
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
function walkMdFiles(dir) {
|
|
47
|
-
if (!fs.existsSync(dir)) return [];
|
|
48
|
-
const results = [];
|
|
49
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
-
const fullPath = path.join(dir, entry.name);
|
|
51
|
-
if (entry.isDirectory()) {
|
|
52
|
-
results.push(...walkMdFiles(fullPath));
|
|
53
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
54
|
-
results.push(fullPath);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return results;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Derive the doc path (id) from an absolute file path within a project dir. */
|
|
61
|
-
function filePathToDocId(filePath, projectDir) {
|
|
62
|
-
return path
|
|
63
|
-
.relative(projectDir, filePath)
|
|
64
|
-
.replace(/\.md$/, "")
|
|
65
|
-
.split(path.sep)
|
|
66
|
-
.join("/");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
// Load config
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
|
|
73
|
-
const configPath = path.join(dataDir, "config.json");
|
|
74
|
-
if (!fs.existsSync(configPath)) {
|
|
75
|
-
console.error(`[ERROR] config.json not found at: ${configPath}`);
|
|
76
|
-
console.error(" Make sure --data-dir points to the right directory.");
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
81
|
-
const projects = config.projects ?? ["default"];
|
|
82
|
-
|
|
83
|
-
console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-links`);
|
|
84
|
-
console.log(`Data dir : ${dataDir}`);
|
|
85
|
-
console.log(`Projects : ${projects.join(", ")}`);
|
|
86
|
-
console.log("");
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// Step 1 — Build the global map: slug → [{ project, fullId }]
|
|
90
|
-
//
|
|
91
|
-
// A "slug" is the last segment of a doc path (the filename without .md).
|
|
92
|
-
// If a slug appears in multiple projects or folders, the reference is ambiguous.
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
/** @type {Map<string, Array<{ project: string, fullId: string }>>} */
|
|
96
|
-
const slugMap = new Map();
|
|
97
|
-
|
|
98
|
-
for (const project of projects) {
|
|
99
|
-
const projectDir = path.join(dataDir, "projects", project);
|
|
100
|
-
for (const filePath of walkMdFiles(projectDir)) {
|
|
101
|
-
const fullId = filePathToDocId(filePath, projectDir);
|
|
102
|
-
const slug = fullId.split("/").pop();
|
|
103
|
-
|
|
104
|
-
if (!slugMap.has(slug)) slugMap.set(slug, []);
|
|
105
|
-
slugMap.get(slug).push({ project, fullId });
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
// Step 2 — Scan every doc and fix links
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
|
|
113
|
-
let totalDocsChanged = 0;
|
|
114
|
-
let totalLinksFixed = 0;
|
|
115
|
-
let totalLinksNotFound = 0;
|
|
116
|
-
let totalLinksAmbiguous = 0;
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Resolve an old slug-only reference to its full path.
|
|
120
|
-
* Returns { resolved: string } on success, or { error: string } otherwise.
|
|
121
|
-
*/
|
|
122
|
-
function resolveSlug(slug) {
|
|
123
|
-
const matches = slugMap.get(slug) ?? [];
|
|
124
|
-
if (matches.length === 0) return { error: "NOT FOUND" };
|
|
125
|
-
if (matches.length > 1) {
|
|
126
|
-
return { error: `AMBIGUOUS → [${matches.map((m) => m.fullId).join(", ")}]` };
|
|
127
|
-
}
|
|
128
|
-
return { resolved: matches[0].fullId };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
for (const project of projects) {
|
|
132
|
-
const projectDir = path.join(dataDir, "projects", project);
|
|
133
|
-
const files = walkMdFiles(projectDir);
|
|
134
|
-
|
|
135
|
-
if (files.length === 0) continue;
|
|
136
|
-
|
|
137
|
-
console.log(`\n### Project: ${project} (${files.length} docs)`);
|
|
138
|
-
console.log("");
|
|
139
|
-
|
|
140
|
-
for (const filePath of files) {
|
|
141
|
-
const fullId = filePathToDocId(filePath, projectDir);
|
|
142
|
-
const raw = fs.readFileSync(filePath, "utf-8");
|
|
143
|
-
const parsed = matter(raw);
|
|
144
|
-
|
|
145
|
-
const docChanges = [];
|
|
146
|
-
let docChanged = false;
|
|
147
|
-
|
|
148
|
-
// -----------------------------------------------------------------------
|
|
149
|
-
// Fix related_docs
|
|
150
|
-
// -----------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
if (Array.isArray(parsed.data.related_docs)) {
|
|
153
|
-
const newRelatedDocs = [];
|
|
154
|
-
|
|
155
|
-
for (const ref of parsed.data.related_docs) {
|
|
156
|
-
// Already a full path — nothing to do
|
|
157
|
-
if (ref.includes("/")) {
|
|
158
|
-
newRelatedDocs.push(ref);
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const result = resolveSlug(ref);
|
|
163
|
-
|
|
164
|
-
if (result.resolved) {
|
|
165
|
-
docChanges.push(` [related_docs] "${ref}" → "${result.resolved}"`);
|
|
166
|
-
newRelatedDocs.push(result.resolved);
|
|
167
|
-
docChanged = true;
|
|
168
|
-
totalLinksFixed++;
|
|
169
|
-
} else {
|
|
170
|
-
docChanges.push(` [related_docs] "${ref}" → ${result.error} (kept as-is)`);
|
|
171
|
-
newRelatedDocs.push(ref);
|
|
172
|
-
if (result.error === "NOT FOUND") totalLinksNotFound++;
|
|
173
|
-
else totalLinksAmbiguous++;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (docChanged) {
|
|
178
|
-
parsed.data.related_docs = newRelatedDocs;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// -----------------------------------------------------------------------
|
|
183
|
-
// Fix inline doc:// links in content
|
|
184
|
-
//
|
|
185
|
-
// Matches: doc://slug or doc://slug#anchor
|
|
186
|
-
// Does NOT touch: doc://folder/slug (already full path)
|
|
187
|
-
// -----------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
// Pattern captures: group1 = slug-or-path, group2 = optional #anchor
|
|
190
|
-
const docLinkRegex = /doc:\/\/([^)#\s]+)(#[^)\s]*)?/g;
|
|
191
|
-
let newContent = parsed.content;
|
|
192
|
-
|
|
193
|
-
// Collect all matches first to avoid mutation during iteration
|
|
194
|
-
const contentMatches = [];
|
|
195
|
-
let m;
|
|
196
|
-
while ((m = docLinkRegex.exec(parsed.content)) !== null) {
|
|
197
|
-
contentMatches.push({ full: m[0], ref: m[1], anchor: m[2] ?? "" });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
for (const { full, ref, anchor } of contentMatches) {
|
|
201
|
-
// Already a full path
|
|
202
|
-
if (ref.includes("/")) continue;
|
|
203
|
-
|
|
204
|
-
const result = resolveSlug(ref);
|
|
205
|
-
|
|
206
|
-
if (result.resolved) {
|
|
207
|
-
const newLink = `doc://${result.resolved}${anchor}`;
|
|
208
|
-
docChanges.push(` [doc link] "${full}" → "${newLink}"`);
|
|
209
|
-
// Replace only this specific occurrence (anchor included for precision)
|
|
210
|
-
newContent = newContent.replaceAll(full, newLink);
|
|
211
|
-
docChanged = true;
|
|
212
|
-
totalLinksFixed++;
|
|
213
|
-
} else {
|
|
214
|
-
docChanges.push(` [doc link] "${full}" → ${result.error} (kept as-is)`);
|
|
215
|
-
if (result.error === "NOT FOUND") totalLinksNotFound++;
|
|
216
|
-
else totalLinksAmbiguous++;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// -----------------------------------------------------------------------
|
|
221
|
-
// Report & write
|
|
222
|
-
// -----------------------------------------------------------------------
|
|
223
|
-
|
|
224
|
-
if (docChanges.length > 0) {
|
|
225
|
-
console.log(` [${fullId}]`);
|
|
226
|
-
docChanges.forEach((c) => console.log(c));
|
|
227
|
-
console.log("");
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (docChanged) {
|
|
231
|
-
totalDocsChanged++;
|
|
232
|
-
if (!dryRun) {
|
|
233
|
-
const output = matter.stringify(newContent, parsed.data);
|
|
234
|
-
fs.writeFileSync(filePath, output, "utf-8");
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
// Summary
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
|
|
244
|
-
console.log("─".repeat(60));
|
|
245
|
-
console.log(`Links fixed : ${totalLinksFixed}`);
|
|
246
|
-
if (totalLinksNotFound > 0) {
|
|
247
|
-
console.log(`Links not found : ${totalLinksNotFound} ← target doc doesn't exist`);
|
|
248
|
-
}
|
|
249
|
-
if (totalLinksAmbiguous > 0) {
|
|
250
|
-
console.log(`Links ambiguous : ${totalLinksAmbiguous} ← same slug in multiple folders, fix manually`);
|
|
251
|
-
}
|
|
252
|
-
console.log(`Docs modified : ${totalDocsChanged}`);
|
|
253
|
-
|
|
254
|
-
if (dryRun) {
|
|
255
|
-
console.log("");
|
|
256
|
-
console.log("[DRY RUN] No files were modified.");
|
|
257
|
-
console.log(" Run without --dry-run to apply the changes.");
|
|
258
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-links.js
|
|
4
|
+
*
|
|
5
|
+
* Migrates inter-document references from the old slug-only format
|
|
6
|
+
* to the new full-path format (folder/name) introduced in Task 10.
|
|
7
|
+
*
|
|
8
|
+
* Fixes two types of links:
|
|
9
|
+
* - related_docs: ["old-slug"] → ["folder/old-slug"]
|
|
10
|
+
* - inline links: (doc://old-slug) → (doc://folder/old-slug)
|
|
11
|
+
* (doc://old-slug#h) → (doc://folder/old-slug#h)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node scripts/migrate-links.js [--dry-run] [--data-dir /path/to/.docs-data]
|
|
15
|
+
*
|
|
16
|
+
* Options:
|
|
17
|
+
* --dry-run Show what would be changed without modifying any file.
|
|
18
|
+
* --data-dir Override the data directory (default: <project-root>/.docs-data).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import path from "path";
|
|
23
|
+
import { fileURLToPath } from "url";
|
|
24
|
+
import matter from "gray-matter";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Setup
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const rootDir = path.join(__dirname, "..");
|
|
32
|
+
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const dryRun = args.includes("--dry-run");
|
|
35
|
+
|
|
36
|
+
const dataDirArgIdx = args.indexOf("--data-dir");
|
|
37
|
+
const dataDir =
|
|
38
|
+
dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
|
|
39
|
+
? args[dataDirArgIdx + 1]
|
|
40
|
+
: path.join(rootDir, ".docs-data");
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function walkMdFiles(dir) {
|
|
47
|
+
if (!fs.existsSync(dir)) return [];
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const fullPath = path.join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
results.push(...walkMdFiles(fullPath));
|
|
53
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
54
|
+
results.push(fullPath);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Derive the doc path (id) from an absolute file path within a project dir. */
|
|
61
|
+
function filePathToDocId(filePath, projectDir) {
|
|
62
|
+
return path
|
|
63
|
+
.relative(projectDir, filePath)
|
|
64
|
+
.replace(/\.md$/, "")
|
|
65
|
+
.split(path.sep)
|
|
66
|
+
.join("/");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Load config
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const configPath = path.join(dataDir, "config.json");
|
|
74
|
+
if (!fs.existsSync(configPath)) {
|
|
75
|
+
console.error(`[ERROR] config.json not found at: ${configPath}`);
|
|
76
|
+
console.error(" Make sure --data-dir points to the right directory.");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
81
|
+
const projects = config.projects ?? ["default"];
|
|
82
|
+
|
|
83
|
+
console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-links`);
|
|
84
|
+
console.log(`Data dir : ${dataDir}`);
|
|
85
|
+
console.log(`Projects : ${projects.join(", ")}`);
|
|
86
|
+
console.log("");
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Step 1 — Build the global map: slug → [{ project, fullId }]
|
|
90
|
+
//
|
|
91
|
+
// A "slug" is the last segment of a doc path (the filename without .md).
|
|
92
|
+
// If a slug appears in multiple projects or folders, the reference is ambiguous.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** @type {Map<string, Array<{ project: string, fullId: string }>>} */
|
|
96
|
+
const slugMap = new Map();
|
|
97
|
+
|
|
98
|
+
for (const project of projects) {
|
|
99
|
+
const projectDir = path.join(dataDir, "projects", project);
|
|
100
|
+
for (const filePath of walkMdFiles(projectDir)) {
|
|
101
|
+
const fullId = filePathToDocId(filePath, projectDir);
|
|
102
|
+
const slug = fullId.split("/").pop();
|
|
103
|
+
|
|
104
|
+
if (!slugMap.has(slug)) slugMap.set(slug, []);
|
|
105
|
+
slugMap.get(slug).push({ project, fullId });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Step 2 — Scan every doc and fix links
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
let totalDocsChanged = 0;
|
|
114
|
+
let totalLinksFixed = 0;
|
|
115
|
+
let totalLinksNotFound = 0;
|
|
116
|
+
let totalLinksAmbiguous = 0;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve an old slug-only reference to its full path.
|
|
120
|
+
* Returns { resolved: string } on success, or { error: string } otherwise.
|
|
121
|
+
*/
|
|
122
|
+
function resolveSlug(slug) {
|
|
123
|
+
const matches = slugMap.get(slug) ?? [];
|
|
124
|
+
if (matches.length === 0) return { error: "NOT FOUND" };
|
|
125
|
+
if (matches.length > 1) {
|
|
126
|
+
return { error: `AMBIGUOUS → [${matches.map((m) => m.fullId).join(", ")}]` };
|
|
127
|
+
}
|
|
128
|
+
return { resolved: matches[0].fullId };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const project of projects) {
|
|
132
|
+
const projectDir = path.join(dataDir, "projects", project);
|
|
133
|
+
const files = walkMdFiles(projectDir);
|
|
134
|
+
|
|
135
|
+
if (files.length === 0) continue;
|
|
136
|
+
|
|
137
|
+
console.log(`\n### Project: ${project} (${files.length} docs)`);
|
|
138
|
+
console.log("");
|
|
139
|
+
|
|
140
|
+
for (const filePath of files) {
|
|
141
|
+
const fullId = filePathToDocId(filePath, projectDir);
|
|
142
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
143
|
+
const parsed = matter(raw);
|
|
144
|
+
|
|
145
|
+
const docChanges = [];
|
|
146
|
+
let docChanged = false;
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
// Fix related_docs
|
|
150
|
+
// -----------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
if (Array.isArray(parsed.data.related_docs)) {
|
|
153
|
+
const newRelatedDocs = [];
|
|
154
|
+
|
|
155
|
+
for (const ref of parsed.data.related_docs) {
|
|
156
|
+
// Already a full path — nothing to do
|
|
157
|
+
if (ref.includes("/")) {
|
|
158
|
+
newRelatedDocs.push(ref);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = resolveSlug(ref);
|
|
163
|
+
|
|
164
|
+
if (result.resolved) {
|
|
165
|
+
docChanges.push(` [related_docs] "${ref}" → "${result.resolved}"`);
|
|
166
|
+
newRelatedDocs.push(result.resolved);
|
|
167
|
+
docChanged = true;
|
|
168
|
+
totalLinksFixed++;
|
|
169
|
+
} else {
|
|
170
|
+
docChanges.push(` [related_docs] "${ref}" → ${result.error} (kept as-is)`);
|
|
171
|
+
newRelatedDocs.push(ref);
|
|
172
|
+
if (result.error === "NOT FOUND") totalLinksNotFound++;
|
|
173
|
+
else totalLinksAmbiguous++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (docChanged) {
|
|
178
|
+
parsed.data.related_docs = newRelatedDocs;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -----------------------------------------------------------------------
|
|
183
|
+
// Fix inline doc:// links in content
|
|
184
|
+
//
|
|
185
|
+
// Matches: doc://slug or doc://slug#anchor
|
|
186
|
+
// Does NOT touch: doc://folder/slug (already full path)
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
// Pattern captures: group1 = slug-or-path, group2 = optional #anchor
|
|
190
|
+
const docLinkRegex = /doc:\/\/([^)#\s]+)(#[^)\s]*)?/g;
|
|
191
|
+
let newContent = parsed.content;
|
|
192
|
+
|
|
193
|
+
// Collect all matches first to avoid mutation during iteration
|
|
194
|
+
const contentMatches = [];
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = docLinkRegex.exec(parsed.content)) !== null) {
|
|
197
|
+
contentMatches.push({ full: m[0], ref: m[1], anchor: m[2] ?? "" });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const { full, ref, anchor } of contentMatches) {
|
|
201
|
+
// Already a full path
|
|
202
|
+
if (ref.includes("/")) continue;
|
|
203
|
+
|
|
204
|
+
const result = resolveSlug(ref);
|
|
205
|
+
|
|
206
|
+
if (result.resolved) {
|
|
207
|
+
const newLink = `doc://${result.resolved}${anchor}`;
|
|
208
|
+
docChanges.push(` [doc link] "${full}" → "${newLink}"`);
|
|
209
|
+
// Replace only this specific occurrence (anchor included for precision)
|
|
210
|
+
newContent = newContent.replaceAll(full, newLink);
|
|
211
|
+
docChanged = true;
|
|
212
|
+
totalLinksFixed++;
|
|
213
|
+
} else {
|
|
214
|
+
docChanges.push(` [doc link] "${full}" → ${result.error} (kept as-is)`);
|
|
215
|
+
if (result.error === "NOT FOUND") totalLinksNotFound++;
|
|
216
|
+
else totalLinksAmbiguous++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// -----------------------------------------------------------------------
|
|
221
|
+
// Report & write
|
|
222
|
+
// -----------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
if (docChanges.length > 0) {
|
|
225
|
+
console.log(` [${fullId}]`);
|
|
226
|
+
docChanges.forEach((c) => console.log(c));
|
|
227
|
+
console.log("");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (docChanged) {
|
|
231
|
+
totalDocsChanged++;
|
|
232
|
+
if (!dryRun) {
|
|
233
|
+
const output = matter.stringify(newContent, parsed.data);
|
|
234
|
+
fs.writeFileSync(filePath, output, "utf-8");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Summary
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
console.log("─".repeat(60));
|
|
245
|
+
console.log(`Links fixed : ${totalLinksFixed}`);
|
|
246
|
+
if (totalLinksNotFound > 0) {
|
|
247
|
+
console.log(`Links not found : ${totalLinksNotFound} ← target doc doesn't exist`);
|
|
248
|
+
}
|
|
249
|
+
if (totalLinksAmbiguous > 0) {
|
|
250
|
+
console.log(`Links ambiguous : ${totalLinksAmbiguous} ← same slug in multiple folders, fix manually`);
|
|
251
|
+
}
|
|
252
|
+
console.log(`Docs modified : ${totalDocsChanged}`);
|
|
253
|
+
|
|
254
|
+
if (dryRun) {
|
|
255
|
+
console.log("");
|
|
256
|
+
console.log("[DRY RUN] No files were modified.");
|
|
257
|
+
console.log(" Run without --dry-run to apply the changes.");
|
|
258
|
+
}
|
|
@@ -1,196 +1,196 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* migrate-paths-to-relative.js
|
|
4
|
-
*
|
|
5
|
-
* One-shot migration that rewrites the absolute paths stored in each doc's
|
|
6
|
-
* `tracked_files[].path` into the portable, code-root-relative form introduced
|
|
7
|
-
* alongside GitHub documentation sync. Portable paths let an agent running on a
|
|
8
|
-
* different machine (where the codebase lives at a different absolute location)
|
|
9
|
-
* resolve tracked files correctly.
|
|
10
|
-
*
|
|
11
|
-
* Resolution reuses the compiled path-resolver (dist/) so it behaves exactly
|
|
12
|
-
* like the running server — RUN `npm run build` FIRST.
|
|
13
|
-
*
|
|
14
|
-
* The conversion uses the code roots of this machine, as the server does: the
|
|
15
|
-
* default root and the per-project roots stored in <data dir>/local-state.json,
|
|
16
|
-
* set from the GUI (project settings) or with the set_code_root tool.
|
|
17
|
-
*
|
|
18
|
-
* A tracked file whose absolute path is NOT under the project's code root is
|
|
19
|
-
* left absolute and reported, so nothing is silently broken.
|
|
20
|
-
*
|
|
21
|
-
* The data dir is resolved like the server: --data-dir flag, else MCP_DATA_DIR,
|
|
22
|
-
* else <package>/data. The MCP_DATA_DIR fallback is what makes npx usable.
|
|
23
|
-
*
|
|
24
|
-
* Usage (from a clone):
|
|
25
|
-
* node scripts/migrate-paths-to-relative.js [--dry-run] [--data-dir <dir>]
|
|
26
|
-
*
|
|
27
|
-
* Usage (npx install — no clone):
|
|
28
|
-
* MCP_DATA_DIR="<your data dir>" \
|
|
29
|
-
* npx -p @imenam/mcp-documentor mcp-documentor-migrate-paths --dry-run
|
|
30
|
-
*
|
|
31
|
-
* This migration is a ONE-TIME operation: run it once on the machine where the
|
|
32
|
-
* stored absolute paths are valid, publish the result (sync_docs / push_docs),
|
|
33
|
-
* and every other machine just pulls and sets its own code roots.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import fs from "fs";
|
|
37
|
-
import path from "path";
|
|
38
|
-
import { fileURLToPath, pathToFileURL } from "url";
|
|
39
|
-
import matter from "gray-matter";
|
|
40
|
-
|
|
41
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
-
const rootDir = path.join(__dirname, "..");
|
|
43
|
-
|
|
44
|
-
const args = process.argv.slice(2);
|
|
45
|
-
const dryRun = args.includes("--dry-run");
|
|
46
|
-
|
|
47
|
-
// Data dir resolution mirrors the server (StorageEngine.getDataDir):
|
|
48
|
-
// --data-dir flag > MCP_DATA_DIR env > <package>/data fallback.
|
|
49
|
-
// The env fallback is what makes `npx` work: the data lives in the user's
|
|
50
|
-
// MCP_DATA_DIR, not inside the npx package cache.
|
|
51
|
-
const dataDirArgIdx = args.indexOf("--data-dir");
|
|
52
|
-
const dataDir =
|
|
53
|
-
dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
|
|
54
|
-
? args[dataDirArgIdx + 1]
|
|
55
|
-
: process.env.MCP_DATA_DIR && process.env.MCP_DATA_DIR.trim() !== ""
|
|
56
|
-
? process.env.MCP_DATA_DIR.trim()
|
|
57
|
-
: path.join(rootDir, "data");
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Reuse the compiled resolver so migration matches runtime behaviour exactly.
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
const resolverPath = path.join(rootDir, "dist", "src", "core", "path-resolver.js");
|
|
64
|
-
const localStatePath = path.join(rootDir, "dist", "src", "core", "local-state.js");
|
|
65
|
-
if (!fs.existsSync(resolverPath) || !fs.existsSync(localStatePath)) {
|
|
66
|
-
console.error(`[ERROR] Compiled resolver not found at: ${resolverPath}`);
|
|
67
|
-
console.error(" Run `npm run build` before this migration.");
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|
|
70
|
-
const { getCodeRoot, toStoredPath, setCodeRootProvider } = await import(pathToFileURL(resolverPath).href);
|
|
71
|
-
const { LocalState } = await import(pathToFileURL(localStatePath).href);
|
|
72
|
-
const localState = new LocalState(dataDir);
|
|
73
|
-
setCodeRootProvider((project) => localState.getCodeRoot(project));
|
|
74
|
-
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Helpers
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
function walkMdFiles(dir) {
|
|
80
|
-
if (!fs.existsSync(dir)) return [];
|
|
81
|
-
const results = [];
|
|
82
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
-
const fullPath = path.join(dir, entry.name);
|
|
84
|
-
if (entry.isDirectory()) {
|
|
85
|
-
results.push(...walkMdFiles(fullPath));
|
|
86
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
87
|
-
results.push(fullPath);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return results;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function filePathToDocId(filePath, projectDir) {
|
|
94
|
-
return path.relative(projectDir, filePath).replace(/\.md$/, "").split(path.sep).join("/");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Load config
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
const configPath = path.join(dataDir, "config.json");
|
|
102
|
-
if (!fs.existsSync(configPath)) {
|
|
103
|
-
console.error(`[ERROR] config.json not found at: ${configPath}`);
|
|
104
|
-
console.error(" Make sure --data-dir points to the right directory.");
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
109
|
-
const projects = config.projects ?? ["default"];
|
|
110
|
-
|
|
111
|
-
console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-paths-to-relative`);
|
|
112
|
-
console.log(`Data dir : ${dataDir}`);
|
|
113
|
-
console.log(`Projects : ${projects.length}`);
|
|
114
|
-
console.log("");
|
|
115
|
-
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// Migrate
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
let totalDocsChanged = 0;
|
|
121
|
-
let totalConverted = 0;
|
|
122
|
-
let totalLeftAbsolute = 0;
|
|
123
|
-
const projectsWithoutRoot = [];
|
|
124
|
-
|
|
125
|
-
for (const project of projects) {
|
|
126
|
-
const root = getCodeRoot(project);
|
|
127
|
-
if (!root) {
|
|
128
|
-
projectsWithoutRoot.push(project);
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const projectDir = path.join(dataDir, "projects", project);
|
|
133
|
-
const files = walkMdFiles(projectDir);
|
|
134
|
-
if (files.length === 0) continue;
|
|
135
|
-
|
|
136
|
-
const docChanges = [];
|
|
137
|
-
|
|
138
|
-
for (const filePath of files) {
|
|
139
|
-
const fullId = filePathToDocId(filePath, projectDir);
|
|
140
|
-
const parsed = matter(fs.readFileSync(filePath, "utf-8"));
|
|
141
|
-
const tracked = parsed.data.tracked_files;
|
|
142
|
-
if (!Array.isArray(tracked) || tracked.length === 0) continue;
|
|
143
|
-
|
|
144
|
-
let docChanged = false;
|
|
145
|
-
for (const tf of tracked) {
|
|
146
|
-
if (!tf || typeof tf.path !== "string") continue;
|
|
147
|
-
if (!path.isAbsolute(tf.path)) continue; // already relative
|
|
148
|
-
|
|
149
|
-
const stored = toStoredPath(tf.path, project);
|
|
150
|
-
if (path.isAbsolute(stored)) {
|
|
151
|
-
// Outside the root (or different drive) — resolver kept it absolute.
|
|
152
|
-
totalLeftAbsolute++;
|
|
153
|
-
docChanges.push(` [${fullId}] KEPT ABSOLUTE (outside root): ${tf.path}`);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
docChanges.push(` [${fullId}] "${tf.path}" → "${stored}"`);
|
|
158
|
-
tf.path = stored;
|
|
159
|
-
docChanged = true;
|
|
160
|
-
totalConverted++;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (docChanged) {
|
|
164
|
-
totalDocsChanged++;
|
|
165
|
-
if (!dryRun) {
|
|
166
|
-
fs.writeFileSync(filePath, matter.stringify(parsed.content, parsed.data), "utf-8");
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (docChanges.length > 0) {
|
|
172
|
-
console.log(`### Project: ${project} (root: ${root})`);
|
|
173
|
-
docChanges.forEach((c) => console.log(c));
|
|
174
|
-
console.log("");
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// Summary
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
console.log("─".repeat(60));
|
|
183
|
-
console.log(`Paths converted to relative : ${totalConverted}`);
|
|
184
|
-
console.log(`Paths kept absolute : ${totalLeftAbsolute} ← outside the code root`);
|
|
185
|
-
console.log(`Docs modified : ${totalDocsChanged}`);
|
|
186
|
-
if (projectsWithoutRoot.length > 0) {
|
|
187
|
-
console.log("");
|
|
188
|
-
console.log(`Projects skipped (no code root configured): ${projectsWithoutRoot.length}`);
|
|
189
|
-
console.log(` ${projectsWithoutRoot.join(", ")}`);
|
|
190
|
-
console.log(" → set their code root (project settings in the GUI, or set_code_root) to migrate them.");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (dryRun) {
|
|
194
|
-
console.log("");
|
|
195
|
-
console.log("[DRY RUN] No files were modified. Run without --dry-run to apply.");
|
|
196
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-paths-to-relative.js
|
|
4
|
+
*
|
|
5
|
+
* One-shot migration that rewrites the absolute paths stored in each doc's
|
|
6
|
+
* `tracked_files[].path` into the portable, code-root-relative form introduced
|
|
7
|
+
* alongside GitHub documentation sync. Portable paths let an agent running on a
|
|
8
|
+
* different machine (where the codebase lives at a different absolute location)
|
|
9
|
+
* resolve tracked files correctly.
|
|
10
|
+
*
|
|
11
|
+
* Resolution reuses the compiled path-resolver (dist/) so it behaves exactly
|
|
12
|
+
* like the running server — RUN `npm run build` FIRST.
|
|
13
|
+
*
|
|
14
|
+
* The conversion uses the code roots of this machine, as the server does: the
|
|
15
|
+
* default root and the per-project roots stored in <data dir>/local-state.json,
|
|
16
|
+
* set from the GUI (project settings) or with the set_code_root tool.
|
|
17
|
+
*
|
|
18
|
+
* A tracked file whose absolute path is NOT under the project's code root is
|
|
19
|
+
* left absolute and reported, so nothing is silently broken.
|
|
20
|
+
*
|
|
21
|
+
* The data dir is resolved like the server: --data-dir flag, else MCP_DATA_DIR,
|
|
22
|
+
* else <package>/data. The MCP_DATA_DIR fallback is what makes npx usable.
|
|
23
|
+
*
|
|
24
|
+
* Usage (from a clone):
|
|
25
|
+
* node scripts/migrate-paths-to-relative.js [--dry-run] [--data-dir <dir>]
|
|
26
|
+
*
|
|
27
|
+
* Usage (npx install — no clone):
|
|
28
|
+
* MCP_DATA_DIR="<your data dir>" \
|
|
29
|
+
* npx -p @imenam/mcp-documentor mcp-documentor-migrate-paths --dry-run
|
|
30
|
+
*
|
|
31
|
+
* This migration is a ONE-TIME operation: run it once on the machine where the
|
|
32
|
+
* stored absolute paths are valid, publish the result (sync_docs / push_docs),
|
|
33
|
+
* and every other machine just pulls and sets its own code roots.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from "fs";
|
|
37
|
+
import path from "path";
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
39
|
+
import matter from "gray-matter";
|
|
40
|
+
|
|
41
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const rootDir = path.join(__dirname, "..");
|
|
43
|
+
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
const dryRun = args.includes("--dry-run");
|
|
46
|
+
|
|
47
|
+
// Data dir resolution mirrors the server (StorageEngine.getDataDir):
|
|
48
|
+
// --data-dir flag > MCP_DATA_DIR env > <package>/data fallback.
|
|
49
|
+
// The env fallback is what makes `npx` work: the data lives in the user's
|
|
50
|
+
// MCP_DATA_DIR, not inside the npx package cache.
|
|
51
|
+
const dataDirArgIdx = args.indexOf("--data-dir");
|
|
52
|
+
const dataDir =
|
|
53
|
+
dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
|
|
54
|
+
? args[dataDirArgIdx + 1]
|
|
55
|
+
: process.env.MCP_DATA_DIR && process.env.MCP_DATA_DIR.trim() !== ""
|
|
56
|
+
? process.env.MCP_DATA_DIR.trim()
|
|
57
|
+
: path.join(rootDir, "data");
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Reuse the compiled resolver so migration matches runtime behaviour exactly.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const resolverPath = path.join(rootDir, "dist", "src", "core", "path-resolver.js");
|
|
64
|
+
const localStatePath = path.join(rootDir, "dist", "src", "core", "local-state.js");
|
|
65
|
+
if (!fs.existsSync(resolverPath) || !fs.existsSync(localStatePath)) {
|
|
66
|
+
console.error(`[ERROR] Compiled resolver not found at: ${resolverPath}`);
|
|
67
|
+
console.error(" Run `npm run build` before this migration.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const { getCodeRoot, toStoredPath, setCodeRootProvider } = await import(pathToFileURL(resolverPath).href);
|
|
71
|
+
const { LocalState } = await import(pathToFileURL(localStatePath).href);
|
|
72
|
+
const localState = new LocalState(dataDir);
|
|
73
|
+
setCodeRootProvider((project) => localState.getCodeRoot(project));
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function walkMdFiles(dir) {
|
|
80
|
+
if (!fs.existsSync(dir)) return [];
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
const fullPath = path.join(dir, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
results.push(...walkMdFiles(fullPath));
|
|
86
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
87
|
+
results.push(fullPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function filePathToDocId(filePath, projectDir) {
|
|
94
|
+
return path.relative(projectDir, filePath).replace(/\.md$/, "").split(path.sep).join("/");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Load config
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const configPath = path.join(dataDir, "config.json");
|
|
102
|
+
if (!fs.existsSync(configPath)) {
|
|
103
|
+
console.error(`[ERROR] config.json not found at: ${configPath}`);
|
|
104
|
+
console.error(" Make sure --data-dir points to the right directory.");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
109
|
+
const projects = config.projects ?? ["default"];
|
|
110
|
+
|
|
111
|
+
console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-paths-to-relative`);
|
|
112
|
+
console.log(`Data dir : ${dataDir}`);
|
|
113
|
+
console.log(`Projects : ${projects.length}`);
|
|
114
|
+
console.log("");
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Migrate
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
let totalDocsChanged = 0;
|
|
121
|
+
let totalConverted = 0;
|
|
122
|
+
let totalLeftAbsolute = 0;
|
|
123
|
+
const projectsWithoutRoot = [];
|
|
124
|
+
|
|
125
|
+
for (const project of projects) {
|
|
126
|
+
const root = getCodeRoot(project);
|
|
127
|
+
if (!root) {
|
|
128
|
+
projectsWithoutRoot.push(project);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const projectDir = path.join(dataDir, "projects", project);
|
|
133
|
+
const files = walkMdFiles(projectDir);
|
|
134
|
+
if (files.length === 0) continue;
|
|
135
|
+
|
|
136
|
+
const docChanges = [];
|
|
137
|
+
|
|
138
|
+
for (const filePath of files) {
|
|
139
|
+
const fullId = filePathToDocId(filePath, projectDir);
|
|
140
|
+
const parsed = matter(fs.readFileSync(filePath, "utf-8"));
|
|
141
|
+
const tracked = parsed.data.tracked_files;
|
|
142
|
+
if (!Array.isArray(tracked) || tracked.length === 0) continue;
|
|
143
|
+
|
|
144
|
+
let docChanged = false;
|
|
145
|
+
for (const tf of tracked) {
|
|
146
|
+
if (!tf || typeof tf.path !== "string") continue;
|
|
147
|
+
if (!path.isAbsolute(tf.path)) continue; // already relative
|
|
148
|
+
|
|
149
|
+
const stored = toStoredPath(tf.path, project);
|
|
150
|
+
if (path.isAbsolute(stored)) {
|
|
151
|
+
// Outside the root (or different drive) — resolver kept it absolute.
|
|
152
|
+
totalLeftAbsolute++;
|
|
153
|
+
docChanges.push(` [${fullId}] KEPT ABSOLUTE (outside root): ${tf.path}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
docChanges.push(` [${fullId}] "${tf.path}" → "${stored}"`);
|
|
158
|
+
tf.path = stored;
|
|
159
|
+
docChanged = true;
|
|
160
|
+
totalConverted++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (docChanged) {
|
|
164
|
+
totalDocsChanged++;
|
|
165
|
+
if (!dryRun) {
|
|
166
|
+
fs.writeFileSync(filePath, matter.stringify(parsed.content, parsed.data), "utf-8");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (docChanges.length > 0) {
|
|
172
|
+
console.log(`### Project: ${project} (root: ${root})`);
|
|
173
|
+
docChanges.forEach((c) => console.log(c));
|
|
174
|
+
console.log("");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Summary
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
console.log("─".repeat(60));
|
|
183
|
+
console.log(`Paths converted to relative : ${totalConverted}`);
|
|
184
|
+
console.log(`Paths kept absolute : ${totalLeftAbsolute} ← outside the code root`);
|
|
185
|
+
console.log(`Docs modified : ${totalDocsChanged}`);
|
|
186
|
+
if (projectsWithoutRoot.length > 0) {
|
|
187
|
+
console.log("");
|
|
188
|
+
console.log(`Projects skipped (no code root configured): ${projectsWithoutRoot.length}`);
|
|
189
|
+
console.log(` ${projectsWithoutRoot.join(", ")}`);
|
|
190
|
+
console.log(" → set their code root (project settings in the GUI, or set_code_root) to migrate them.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (dryRun) {
|
|
194
|
+
console.log("");
|
|
195
|
+
console.log("[DRY RUN] No files were modified. Run without --dry-run to apply.");
|
|
196
|
+
}
|