@imenam/mcp-documentor 1.0.86 → 1.0.88
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 +7 -5
- package/dist/shared/read-only.js +1 -1
- package/dist/shared/schemas/doc.schema.d.ts +3 -1
- package/dist/shared/schemas/doc.schema.js +13 -1
- package/dist/src/core/code-source.d.ts +32 -0
- package/dist/src/core/code-source.js +23 -4
- package/dist/src/core/embedding-engine.d.ts +1 -1
- package/dist/src/core/embedding-engine.js +3 -3
- package/dist/src/core/embedding-store.d.ts +1 -1
- package/dist/src/core/embedding-store.js +4 -4
- package/dist/src/core/git-sync.d.ts +5 -0
- package/dist/src/core/git-sync.js +29 -2
- package/dist/src/core/github-code-api.d.ts +3 -8
- package/dist/src/core/github-code-api.js +67 -36
- package/dist/src/core/github-log.d.ts +38 -0
- package/dist/src/core/github-log.js +64 -0
- package/dist/src/core/log-reader.d.ts +47 -0
- package/dist/src/core/log-reader.js +106 -0
- package/dist/src/core/storage-engine.d.ts +27 -2
- package/dist/src/core/storage-engine.js +99 -23
- package/dist/src/gui-worker.js +58 -1
- package/dist/src/index.js +78 -12
- package/dist/src/tools/code-sources.d.ts +9 -0
- package/dist/src/tools/code-sources.js +18 -16
- package/dist/src/tools/create-project.d.ts +19 -0
- package/dist/src/tools/create-project.js +42 -0
- package/dist/src/tools/delete-project.d.ts +16 -0
- package/dist/src/tools/delete-project.js +25 -0
- package/dist/src/tools/git-diff-doc.js +123 -98
- package/dist/src/tools/move-doc.js +20 -6
- package/dist/src/tools/patch-doc.js +14 -3
- package/gui/dist/assets/{arc-DBgCQU2O.js → arc-s4GL8QZ_.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-CfDERg0r.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-CjDoEG3-.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-9o7f2UhK.js} +1 -1
- package/gui/dist/assets/channel-BsKHRH5P.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B37ouRYO.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-Dcq44Skv.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-CLyM-RQf.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-BjzEo2iz.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-Co03OHPi.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-ClbF2rAp.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-CN5cTCWu.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Bg4-kEPK.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-DphzXyc8.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DphzXyc8.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-6Dkp59bE.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-2dD-ID2T.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-DBE2f2Lb.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-CkKPHiH9.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-uWtGAF2N.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-DyQaWg_0.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-H_DfuB5t.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-Cj9K5EXA.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-BYF24Jvt.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-D93LjmhI.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-EVTcusDb.js} +1 -1
- package/gui/dist/assets/index-CtPuh-0k.css +1 -0
- package/gui/dist/assets/{index-DPGmAc6z.js → index-DvVxuv5N.js} +154 -152
- package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-DeGbaexj.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-DNdk7sPJ.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-yLLfyn3W.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-D8yvVgr8.js} +1 -1
- package/gui/dist/assets/{linear-BipFpFah.js → linear-j3lplktA.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-t9adVeMH.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-6Yrvo8h8.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-C_3Su6rX.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-B1qYSC11.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-D3Ervn-u.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CKgTeWBS.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-D_PjEdFH.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-KPbsissC.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-HeWRiNHu.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-CREk7vzm.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-BWtRQ7ZH.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-BvlqWWwp.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-BCjv6pwc.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/gui/dist/assets/channel-B_cci0fU.js +0 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
- package/gui/dist/assets/index-CxCC1gxu.css +0 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
5
|
+
const logger = createLogger("GitHub");
|
|
6
|
+
export const GITHUB_LOG_FILE = "github.log";
|
|
7
|
+
const triggerStorage = new AsyncLocalStorage();
|
|
8
|
+
/** Runs `fn` with `trigger` recorded as the cause of every GitHub call it makes. */
|
|
9
|
+
export function runTriggeredBy(trigger, fn) {
|
|
10
|
+
return triggerStorage.run(trigger, fn);
|
|
11
|
+
}
|
|
12
|
+
function currentTrigger() {
|
|
13
|
+
return triggerStorage.getStore() ?? "server";
|
|
14
|
+
}
|
|
15
|
+
let stream = null;
|
|
16
|
+
/** Opens github.log in `logDir` for appending. */
|
|
17
|
+
export function initGitHubLog(logDir) {
|
|
18
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
19
|
+
const file = path.join(logDir, GITHUB_LOG_FILE);
|
|
20
|
+
stream = fs.createWriteStream(file, { flags: "a", encoding: "utf8" });
|
|
21
|
+
stream.on("error", (err) => {
|
|
22
|
+
stream = null;
|
|
23
|
+
logger.error(`Cannot write ${file}, GitHub calls are no longer recorded there: ${err.message}`);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Masks credentials embedded in URLs or tokens quoted verbatim. */
|
|
27
|
+
export function redactSecrets(text, secrets = []) {
|
|
28
|
+
let out = text.replace(/(https?:\/\/)[^@\s/]+@/gi, "$1***@");
|
|
29
|
+
for (const secret of secrets) {
|
|
30
|
+
if (secret)
|
|
31
|
+
out = out.split(secret).join("***");
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** "owner/repo" for a GitHub URL, the host and path for any other remote. */
|
|
36
|
+
export function describeRemote(remoteUrl) {
|
|
37
|
+
const cleaned = redactSecrets(remoteUrl).replace(/^https?:\/\/(\*\*\*@)?/i, "").replace(/^git@/i, "").replace(/\.git$/i, "");
|
|
38
|
+
const github = cleaned.match(/^github\.com[/:](.+)$/i);
|
|
39
|
+
return github ? github[1] : cleaned.replace(":", "/");
|
|
40
|
+
}
|
|
41
|
+
/** Appends one call to github.log and mirrors it in server.log. */
|
|
42
|
+
export function recordGitHubCall(call) {
|
|
43
|
+
const entry = {
|
|
44
|
+
ts: new Date().toISOString(),
|
|
45
|
+
kind: call.kind,
|
|
46
|
+
action: call.action,
|
|
47
|
+
command: call.command,
|
|
48
|
+
repo: call.repo,
|
|
49
|
+
trigger: currentTrigger(),
|
|
50
|
+
ok: call.ok,
|
|
51
|
+
status: call.status,
|
|
52
|
+
durationMs: call.durationMs,
|
|
53
|
+
...(call.error ? { error: call.error } : {}),
|
|
54
|
+
...(call.rateLimit ? { rateLimit: call.rateLimit } : {}),
|
|
55
|
+
};
|
|
56
|
+
stream?.write(`${JSON.stringify(entry)}\n`);
|
|
57
|
+
const summary = `${entry.command} · ${entry.status} · ${entry.durationMs} ms · ${entry.trigger}`;
|
|
58
|
+
if (entry.ok)
|
|
59
|
+
logger.info(summary);
|
|
60
|
+
else if (entry.status === "404" || entry.status === "422")
|
|
61
|
+
logger.warn(`${summary} · ${entry.error ?? ""}`);
|
|
62
|
+
else
|
|
63
|
+
logger.error(`${summary} · ${entry.error ?? ""}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type GitHubLogEntry } from "./github-log.js";
|
|
2
|
+
export declare const SERVER_LOG_FILE = "server.log";
|
|
3
|
+
export declare const LOG_SOURCES: {
|
|
4
|
+
readonly server: "server.log";
|
|
5
|
+
readonly github: "github.log";
|
|
6
|
+
};
|
|
7
|
+
export type LogSourceId = keyof typeof LOG_SOURCES;
|
|
8
|
+
export declare function isLogSource(id: string): id is LogSourceId;
|
|
9
|
+
export interface LogFileInfo {
|
|
10
|
+
id: LogSourceId;
|
|
11
|
+
file: string;
|
|
12
|
+
path: string;
|
|
13
|
+
exists: boolean;
|
|
14
|
+
size: number;
|
|
15
|
+
mtime: string | null;
|
|
16
|
+
}
|
|
17
|
+
/** One line of server.log, with the lines that followed it without a timestamp of their own. */
|
|
18
|
+
export interface ServerLogEntry {
|
|
19
|
+
ts: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
process: string;
|
|
22
|
+
level: "DEBUG" | "INFO" | "WARN" | "ERROR";
|
|
23
|
+
scope: string;
|
|
24
|
+
message: string;
|
|
25
|
+
continuation: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface GitHubLogLine extends GitHubLogEntry {
|
|
28
|
+
raw: string;
|
|
29
|
+
}
|
|
30
|
+
/** A window on the end of a log file. `partial` is true when older lines lie before it. */
|
|
31
|
+
export interface LogWindow<T> {
|
|
32
|
+
file: LogFileInfo;
|
|
33
|
+
partial: boolean;
|
|
34
|
+
entries: T[];
|
|
35
|
+
/** Lines that could not be parsed, left out of `entries`. */
|
|
36
|
+
unreadable: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function logFileInfo(logDir: string, id: LogSourceId): LogFileInfo;
|
|
39
|
+
/**
|
|
40
|
+
* Parses server.log lines as written by the shared logger:
|
|
41
|
+
* [ISO date] [PID n] [process] [LEVEL] [scope] message
|
|
42
|
+
* Text printed straight to the console carries no level nor scope: it is read as INFO from
|
|
43
|
+
* "console". A line without the date prefix continues the entry above it (stack traces).
|
|
44
|
+
*/
|
|
45
|
+
export declare function readServerLog(logDir: string): LogWindow<ServerLogEntry>;
|
|
46
|
+
/** Parses github.log: one JSON object per line. */
|
|
47
|
+
export declare function readGitHubLog(logDir: string): LogWindow<GitHubLogLine>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { GITHUB_LOG_FILE } from "./github-log.js";
|
|
4
|
+
export const SERVER_LOG_FILE = "server.log";
|
|
5
|
+
export const LOG_SOURCES = {
|
|
6
|
+
server: SERVER_LOG_FILE,
|
|
7
|
+
github: GITHUB_LOG_FILE,
|
|
8
|
+
};
|
|
9
|
+
export function isLogSource(id) {
|
|
10
|
+
return Object.prototype.hasOwnProperty.call(LOG_SOURCES, id);
|
|
11
|
+
}
|
|
12
|
+
/** Bytes read from the end of each file: the most recent part, which is what the viewer shows. */
|
|
13
|
+
const TAIL_BYTES = {
|
|
14
|
+
server: 4 * 1024 * 1024,
|
|
15
|
+
github: 8 * 1024 * 1024,
|
|
16
|
+
};
|
|
17
|
+
export function logFileInfo(logDir, id) {
|
|
18
|
+
const filePath = path.join(logDir, LOG_SOURCES[id]);
|
|
19
|
+
const stat = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
file: LOG_SOURCES[id],
|
|
23
|
+
path: filePath,
|
|
24
|
+
exists: !!stat,
|
|
25
|
+
size: stat?.size ?? 0,
|
|
26
|
+
mtime: stat ? stat.mtime.toISOString() : null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** The last `maxBytes` of a file, starting on a whole line. */
|
|
30
|
+
function readTail(filePath, size, maxBytes) {
|
|
31
|
+
const start = Math.max(0, size - maxBytes);
|
|
32
|
+
const length = size - start;
|
|
33
|
+
const buffer = Buffer.alloc(length);
|
|
34
|
+
const fd = fs.openSync(filePath, "r");
|
|
35
|
+
try {
|
|
36
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
fs.closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
let text = buffer.toString("utf8");
|
|
42
|
+
if (start > 0)
|
|
43
|
+
text = text.slice(text.indexOf("\n") + 1);
|
|
44
|
+
return { text, partial: start > 0 };
|
|
45
|
+
}
|
|
46
|
+
const LINE_PREFIX = /^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\] \[PID (\d+)\] \[([^\]]+)\] (.*)$/;
|
|
47
|
+
const LEVEL_AND_SCOPE = /^\[(DEBUG|INFO|WARN|ERROR)\] \[([^\]]+)\] ?(.*)$/;
|
|
48
|
+
/**
|
|
49
|
+
* Parses server.log lines as written by the shared logger:
|
|
50
|
+
* [ISO date] [PID n] [process] [LEVEL] [scope] message
|
|
51
|
+
* Text printed straight to the console carries no level nor scope: it is read as INFO from
|
|
52
|
+
* "console". A line without the date prefix continues the entry above it (stack traces).
|
|
53
|
+
*/
|
|
54
|
+
export function readServerLog(logDir) {
|
|
55
|
+
const file = logFileInfo(logDir, "server");
|
|
56
|
+
if (!file.exists)
|
|
57
|
+
return { file, partial: false, entries: [], unreadable: 0 };
|
|
58
|
+
const { text, partial } = readTail(file.path, file.size, TAIL_BYTES.server);
|
|
59
|
+
const entries = [];
|
|
60
|
+
let unreadable = 0;
|
|
61
|
+
for (const line of text.split(/\r?\n/)) {
|
|
62
|
+
if (!line)
|
|
63
|
+
continue;
|
|
64
|
+
const prefix = line.match(LINE_PREFIX);
|
|
65
|
+
if (!prefix) {
|
|
66
|
+
const previous = entries[entries.length - 1];
|
|
67
|
+
if (previous)
|
|
68
|
+
previous.continuation.push(line);
|
|
69
|
+
else
|
|
70
|
+
unreadable++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const [, ts, pid, processLabel, rest] = prefix;
|
|
74
|
+
const tagged = rest.match(LEVEL_AND_SCOPE);
|
|
75
|
+
entries.push({
|
|
76
|
+
ts,
|
|
77
|
+
pid: Number(pid),
|
|
78
|
+
process: processLabel,
|
|
79
|
+
level: tagged ? tagged[1] : "INFO",
|
|
80
|
+
scope: tagged ? tagged[2] : "console",
|
|
81
|
+
message: tagged ? tagged[3] : rest,
|
|
82
|
+
continuation: [],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { file, partial, entries, unreadable };
|
|
86
|
+
}
|
|
87
|
+
/** Parses github.log: one JSON object per line. */
|
|
88
|
+
export function readGitHubLog(logDir) {
|
|
89
|
+
const file = logFileInfo(logDir, "github");
|
|
90
|
+
if (!file.exists)
|
|
91
|
+
return { file, partial: false, entries: [], unreadable: 0 };
|
|
92
|
+
const { text, partial } = readTail(file.path, file.size, TAIL_BYTES.github);
|
|
93
|
+
const entries = [];
|
|
94
|
+
let unreadable = 0;
|
|
95
|
+
for (const line of text.split(/\r?\n/)) {
|
|
96
|
+
if (!line.trim())
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
entries.push({ ...JSON.parse(line), raw: line });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
unreadable++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { file, partial, entries, unreadable };
|
|
106
|
+
}
|
|
@@ -42,6 +42,12 @@ export declare class StorageEngine {
|
|
|
42
42
|
/** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
|
|
43
43
|
getSyncConfig(): SyncConfig | undefined;
|
|
44
44
|
ensureProject(project: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Removes a project that holds nothing but its settings file. Any other file —
|
|
47
|
+
* doc, asset or otherwise — makes it refuse, listing what it found.
|
|
48
|
+
*/
|
|
49
|
+
deleteProject(project: string): void;
|
|
50
|
+
private walkFiles;
|
|
45
51
|
private walkMdFiles;
|
|
46
52
|
private docIdToFilePath;
|
|
47
53
|
private cleanEmptyDirs;
|
|
@@ -58,9 +64,28 @@ export declare class StorageEngine {
|
|
|
58
64
|
*/
|
|
59
65
|
listDocsDetailed(project: string): Promise<DocListing[]>;
|
|
60
66
|
deleteDoc(id: string, project: string): Promise<boolean>;
|
|
61
|
-
|
|
67
|
+
/** Known project names, longest first, so a name is never matched as the prefix of a longer one. */
|
|
68
|
+
private projectsLongestFirst;
|
|
69
|
+
/**
|
|
70
|
+
* Matches every `doc://` URL: group 1 is the project of a qualified reference (project names
|
|
71
|
+
* may contain spaces), undefined for a bare one; group 2 is the doc id.
|
|
72
|
+
*/
|
|
73
|
+
private docUrlRegex;
|
|
74
|
+
/** Splits a `related_docs` entry into its project, when qualified with a known one, and its id. */
|
|
75
|
+
private parseDocRef;
|
|
76
|
+
/**
|
|
77
|
+
* Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own references are
|
|
78
|
+
* rewritten so they keep their target: bare ones are qualified with the source project, and any
|
|
79
|
+
* reference into the target project becomes bare. The history records a delete in the source
|
|
80
|
+
* project and a create in the target.
|
|
81
|
+
*/
|
|
82
|
+
moveDoc(oldId: string, project: string, newId: string, newProject: string): Promise<boolean>;
|
|
62
83
|
getBundles(): ExportBundle[];
|
|
63
84
|
saveBundles(bundles: ExportBundle[]): void;
|
|
64
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
|
|
87
|
+
* `newProject`, qualified everywhere else.
|
|
88
|
+
*/
|
|
89
|
+
rewriteLinks(oldId: string, project: string, newId: string, newProject: string): Promise<string[]>;
|
|
65
90
|
}
|
|
66
91
|
export {};
|
|
@@ -91,6 +91,39 @@ export class StorageEngine {
|
|
|
91
91
|
}
|
|
92
92
|
logger.info(`Project "${project}" ensured at ${projectDir}`);
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Removes a project that holds nothing but its settings file. Any other file —
|
|
96
|
+
* doc, asset or otherwise — makes it refuse, listing what it found.
|
|
97
|
+
*/
|
|
98
|
+
deleteProject(project) {
|
|
99
|
+
const projectDir = this.getProjectDir(project);
|
|
100
|
+
const contents = this.walkFiles(projectDir)
|
|
101
|
+
.map((file) => path.relative(projectDir, file).split(path.sep).join("/"))
|
|
102
|
+
.filter((rel) => rel !== "project.json");
|
|
103
|
+
if (contents.length > 0) {
|
|
104
|
+
const shown = contents.slice(0, 20).map((rel) => ` - ${rel}`).join("\n");
|
|
105
|
+
const more = contents.length > 20 ? `\n … and ${contents.length - 20} more` : "";
|
|
106
|
+
throw new Error(`Project "${project}" is not empty (${contents.length} file(s)) — nothing deleted. Move or delete its content first:\n${shown}${more}`);
|
|
107
|
+
}
|
|
108
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
109
|
+
const config = this.getConfig();
|
|
110
|
+
config.projects = config.projects.filter((p) => p !== project);
|
|
111
|
+
this.saveConfig(config);
|
|
112
|
+
logger.info(`Project "${project}" deleted`);
|
|
113
|
+
}
|
|
114
|
+
walkFiles(dir) {
|
|
115
|
+
if (!fs.existsSync(dir))
|
|
116
|
+
return [];
|
|
117
|
+
const results = [];
|
|
118
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
119
|
+
const fullPath = path.join(dir, entry.name);
|
|
120
|
+
if (entry.isDirectory())
|
|
121
|
+
results.push(...this.walkFiles(fullPath));
|
|
122
|
+
else
|
|
123
|
+
results.push(fullPath);
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
94
127
|
walkMdFiles(dir) {
|
|
95
128
|
if (!fs.existsSync(dir))
|
|
96
129
|
return [];
|
|
@@ -227,24 +260,63 @@ export class StorageEngine {
|
|
|
227
260
|
logger.info(`Doc deleted: ${filePath}`);
|
|
228
261
|
return true;
|
|
229
262
|
}
|
|
230
|
-
|
|
263
|
+
/** Known project names, longest first, so a name is never matched as the prefix of a longer one. */
|
|
264
|
+
projectsLongestFirst() {
|
|
265
|
+
return [...this.getConfig().projects].sort((a, b) => b.length - a.length);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Matches every `doc://` URL: group 1 is the project of a qualified reference (project names
|
|
269
|
+
* may contain spaces), undefined for a bare one; group 2 is the doc id.
|
|
270
|
+
*/
|
|
271
|
+
docUrlRegex() {
|
|
272
|
+
const projects = this.projectsLongestFirst().map(escapeRegex).join("|");
|
|
273
|
+
const qualifier = projects ? `(?:(${projects}):)?` : "()?";
|
|
274
|
+
return new RegExp(`doc://${qualifier}([^):#\\s]+)(?=[)#\\s]|$)`, "g");
|
|
275
|
+
}
|
|
276
|
+
/** Splits a `related_docs` entry into its project, when qualified with a known one, and its id. */
|
|
277
|
+
parseDocRef(ref) {
|
|
278
|
+
const project = this.projectsLongestFirst().find((p) => ref.startsWith(`${p}:`));
|
|
279
|
+
return project ? { project, id: ref.slice(project.length + 1) } : { id: ref };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own references are
|
|
283
|
+
* rewritten so they keep their target: bare ones are qualified with the source project, and any
|
|
284
|
+
* reference into the target project becomes bare. The history records a delete in the source
|
|
285
|
+
* project and a create in the target.
|
|
286
|
+
*/
|
|
287
|
+
async moveDoc(oldId, project, newId, newProject) {
|
|
231
288
|
const projectDir = this.getProjectDir(project);
|
|
232
289
|
const oldFilePath = this.docIdToFilePath(oldId, projectDir);
|
|
233
290
|
if (!fs.existsSync(oldFilePath))
|
|
234
291
|
return false;
|
|
235
|
-
const
|
|
292
|
+
const newProjectDir = this.getProjectDir(newProject);
|
|
293
|
+
const newFilePath = this.docIdToFilePath(newId, newProjectDir);
|
|
236
294
|
if (fs.existsSync(newFilePath)) {
|
|
237
|
-
throw new Error(`A document already exists at path "${newId}".`);
|
|
295
|
+
throw new Error(`A document already exists at path "${newId}" in project "${newProject}".`);
|
|
238
296
|
}
|
|
239
297
|
const doc = await this.getDoc(oldId, project);
|
|
240
298
|
if (!doc)
|
|
241
299
|
return false;
|
|
300
|
+
const crossProject = newProject !== project;
|
|
301
|
+
if (crossProject) {
|
|
302
|
+
this.historyStore?.record(project, doc, "delete");
|
|
303
|
+
const relocate = (ref) => {
|
|
304
|
+
const target = ref.project ?? project;
|
|
305
|
+
return target === newProject ? ref.id : `${target}:${ref.id}`;
|
|
306
|
+
};
|
|
307
|
+
doc.content = doc.content.replace(this.docUrlRegex(), (_match, refProject, id) => `doc://${relocate({ project: refProject, id })}`);
|
|
308
|
+
doc.related_docs = doc.related_docs.map((ref) => {
|
|
309
|
+
const parsed = this.parseDocRef(ref);
|
|
310
|
+
// A qualifier naming no known project is left as is: it cannot be resolved either way.
|
|
311
|
+
return !parsed.project && ref.includes(":") ? ref : relocate(parsed);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
242
314
|
const newParts = newId.split("/");
|
|
243
315
|
doc.id = newId;
|
|
244
316
|
doc.folder = newParts.length > 1 ? newParts.slice(0, -1).join("/") : undefined;
|
|
245
317
|
doc.updatedAt = new Date().toISOString();
|
|
246
318
|
fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
|
|
247
|
-
await this.saveDoc(doc,
|
|
319
|
+
await this.saveDoc(doc, newProject, crossProject ? "create" : "update");
|
|
248
320
|
fs.unlinkSync(oldFilePath);
|
|
249
321
|
this.cleanEmptyDirs(path.dirname(oldFilePath), projectDir);
|
|
250
322
|
logger.info(`Doc moved: ${oldFilePath} → ${newFilePath}`);
|
|
@@ -265,67 +337,71 @@ export class StorageEngine {
|
|
|
265
337
|
const p = path.join(this.getDataDir(), "export-bundles.json");
|
|
266
338
|
fs.writeFileSync(p, JSON.stringify(bundles, null, 2), "utf-8");
|
|
267
339
|
}
|
|
268
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
|
|
342
|
+
* `newProject`, qualified everywhere else.
|
|
343
|
+
*/
|
|
344
|
+
async rewriteLinks(oldId, project, newId, newProject) {
|
|
269
345
|
const config = this.getConfig();
|
|
270
346
|
const proj = project;
|
|
347
|
+
const crossProject = newProject !== proj;
|
|
348
|
+
const targetFrom = (docProject) => (docProject === newProject ? newId : `${newProject}:${newId}`);
|
|
349
|
+
const boundary = "(?=[)#\\s]|$)";
|
|
350
|
+
const qualifiedOld = new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}${boundary}`, "g");
|
|
271
351
|
const updated = [];
|
|
272
|
-
// Within the
|
|
352
|
+
// Within the source project: rewrite bare refs and explicit self-cross-project refs
|
|
273
353
|
const sameProjDocs = await this.listDocs(proj);
|
|
274
354
|
for (const summary of sameProjDocs) {
|
|
275
|
-
if (summary.id === newId)
|
|
355
|
+
if (!crossProject && summary.id === newId)
|
|
276
356
|
continue;
|
|
277
357
|
const doc = await this.getDoc(summary.id, proj);
|
|
278
358
|
if (!doc)
|
|
279
359
|
continue;
|
|
360
|
+
const target = targetFrom(proj);
|
|
280
361
|
let changed = false;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
362
|
+
const newContent = doc.content
|
|
363
|
+
.replace(new RegExp(`doc://${escapeRegex(oldId)}${boundary}`, "g"), `doc://${target}`)
|
|
364
|
+
.replace(qualifiedOld, `doc://${target}`);
|
|
284
365
|
if (newContent !== doc.content) {
|
|
285
366
|
doc.content = newContent;
|
|
286
367
|
changed = true;
|
|
287
368
|
}
|
|
288
|
-
|
|
289
|
-
if (ref === oldId) {
|
|
290
|
-
changed = true;
|
|
291
|
-
return newId;
|
|
292
|
-
}
|
|
293
|
-
if (ref === `${proj}:${oldId}`) {
|
|
369
|
+
doc.related_docs = doc.related_docs.map((ref) => {
|
|
370
|
+
if (ref === oldId || ref === `${proj}:${oldId}`) {
|
|
294
371
|
changed = true;
|
|
295
|
-
return
|
|
372
|
+
return target;
|
|
296
373
|
}
|
|
297
374
|
return ref;
|
|
298
375
|
});
|
|
299
|
-
doc.related_docs = newRelated;
|
|
300
376
|
if (changed) {
|
|
301
377
|
doc.updatedAt = new Date().toISOString();
|
|
302
378
|
await this.saveDoc(doc, proj);
|
|
303
379
|
updated.push(summary.id);
|
|
304
380
|
}
|
|
305
381
|
}
|
|
306
|
-
// In other projects: rewrite cross-project refs pointing to
|
|
382
|
+
// In other projects: rewrite cross-project refs pointing to the source project
|
|
307
383
|
for (const otherProj of config.projects) {
|
|
308
384
|
if (otherProj === proj)
|
|
309
385
|
continue;
|
|
310
386
|
const otherDocs = await this.listDocs(otherProj);
|
|
387
|
+
const target = targetFrom(otherProj);
|
|
311
388
|
for (const summary of otherDocs) {
|
|
312
389
|
const doc = await this.getDoc(summary.id, otherProj);
|
|
313
390
|
if (!doc)
|
|
314
391
|
continue;
|
|
315
392
|
let changed = false;
|
|
316
|
-
const newContent = doc.content.replace(
|
|
393
|
+
const newContent = doc.content.replace(qualifiedOld, `doc://${target}`);
|
|
317
394
|
if (newContent !== doc.content) {
|
|
318
395
|
doc.content = newContent;
|
|
319
396
|
changed = true;
|
|
320
397
|
}
|
|
321
|
-
|
|
398
|
+
doc.related_docs = doc.related_docs.map((ref) => {
|
|
322
399
|
if (ref === `${proj}:${oldId}`) {
|
|
323
400
|
changed = true;
|
|
324
|
-
return
|
|
401
|
+
return target;
|
|
325
402
|
}
|
|
326
403
|
return ref;
|
|
327
404
|
});
|
|
328
|
-
doc.related_docs = newRelated;
|
|
329
405
|
if (changed) {
|
|
330
406
|
doc.updatedAt = new Date().toISOString();
|
|
331
407
|
await this.saveDoc(doc, otherProj);
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -7,12 +7,14 @@ import { IPCMessageSchema } from "../shared/schemas/doc.schema.js";
|
|
|
7
7
|
import { isReadOnly } from "../shared/read-only.js";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import fs from "fs";
|
|
10
|
-
import { execFile } from "child_process";
|
|
10
|
+
import { execFile, spawn } from "child_process";
|
|
11
|
+
import { Readable } from "stream";
|
|
11
12
|
import { promisify } from "util";
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
import { fileURLToPath } from "url";
|
|
14
15
|
import { createLogger, setupLogging } from "@imenam/mcp-gui-interface";
|
|
15
16
|
import { resolveLogDir } from "./core/log-dir.js";
|
|
17
|
+
import { isLogSource, logFileInfo, readGitHubLog, readServerLog } from "./core/log-reader.js";
|
|
16
18
|
import matter from "gray-matter";
|
|
17
19
|
import JSZip from "jszip";
|
|
18
20
|
import puppeteer from "puppeteer-core";
|
|
@@ -427,6 +429,61 @@ app.post("/api/code-roots/apply", async (c) => {
|
|
|
427
429
|
app.get("/api/github-token", (c) => relay(c, "GET_GITHUB_TOKEN", {}, 15000));
|
|
428
430
|
app.get("/api/code-source-migration", (c) => relay(c, "GET_CODE_SOURCE_MIGRATION", {}, 5000));
|
|
429
431
|
app.post("/api/code-source-migration/ack", (c) => relay(c, "ACK_CODE_SOURCE_MIGRATION", {}, 5000));
|
|
432
|
+
// --- Logs ---
|
|
433
|
+
// Read straight from the log directory: both processes write there, and reading needs no IPC.
|
|
434
|
+
const logDir = resolveLogDir(rootDir);
|
|
435
|
+
app.get("/api/logs", (c) => c.json({ dir: logDir, files: [logFileInfo(logDir, "server"), logFileInfo(logDir, "github")] }));
|
|
436
|
+
// `knownSize` and `knownMtime` echo the file state the client already holds: when the file has
|
|
437
|
+
// not changed, nothing is re-read.
|
|
438
|
+
app.get("/api/logs/:source", (c) => {
|
|
439
|
+
const source = c.req.param("source");
|
|
440
|
+
if (!isLogSource(source))
|
|
441
|
+
return c.json({ error: `Unknown log "${source}"` }, 404);
|
|
442
|
+
const info = logFileInfo(logDir, source);
|
|
443
|
+
if (c.req.query("knownSize") === String(info.size) && c.req.query("knownMtime") === (info.mtime ?? "")) {
|
|
444
|
+
return c.json({ unchanged: true, file: info });
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
return c.json(source === "server" ? readServerLog(logDir) : readGitHubLog(logDir));
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
return c.json({ error: `Cannot read ${info.path}: ${err.message}` }, 500);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
app.get("/api/logs/:source/download", (c) => {
|
|
454
|
+
const source = c.req.param("source");
|
|
455
|
+
if (!isLogSource(source))
|
|
456
|
+
return c.json({ error: `Unknown log "${source}"` }, 404);
|
|
457
|
+
const info = logFileInfo(logDir, source);
|
|
458
|
+
if (!info.exists)
|
|
459
|
+
return c.json({ error: `${info.path} does not exist yet` }, 404);
|
|
460
|
+
const body = Readable.toWeb(fs.createReadStream(info.path));
|
|
461
|
+
return new Response(body, {
|
|
462
|
+
headers: {
|
|
463
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
464
|
+
"Content-Length": String(info.size),
|
|
465
|
+
"Content-Disposition": `attachment; filename="${info.file}"`,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
// Opens the log directory in the file manager of the machine running the server.
|
|
470
|
+
app.post("/api/logs/open-folder", async (c) => {
|
|
471
|
+
const command = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
472
|
+
try {
|
|
473
|
+
await new Promise((resolve, reject) => {
|
|
474
|
+
const child = spawn(command, [logDir], { detached: true, stdio: "ignore" });
|
|
475
|
+
child.once("error", reject);
|
|
476
|
+
child.once("spawn", () => {
|
|
477
|
+
child.unref();
|
|
478
|
+
resolve();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
return c.json({ ok: true });
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
return c.json({ error: `Cannot open ${logDir} with ${command}: ${err.message}` }, 500);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
430
487
|
app.get("/api/projects", async (c) => {
|
|
431
488
|
const result = await ipcRequest("LIST_PROJECTS");
|
|
432
489
|
if (result.error)
|