dsh-codegraph-init 0.0.0-stage → 0.1.0
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +63 -2
- package/cordis.patch.yml +3 -0
- package/icon.svg +8 -0
- package/lib/client.js +691 -0
- package/lib/index.js +998 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codegraph wiring, host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one Typert Remote service, `codegraphInstall`, that drives the
|
|
5
|
+
* npm-published codegraph CLI from a browser dialog:
|
|
6
|
+
*
|
|
7
|
+
* - `status` reports whether the CLI exists, whether a `codegraph` MCP server
|
|
8
|
+
* is already wired into this DSH home, and whether the installed CLI knows
|
|
9
|
+
* the `dsh` installer target.
|
|
10
|
+
* - `start` launches one install or uninstall run and returns its id at once;
|
|
11
|
+
* the process keeps streaming into a bounded line buffer.
|
|
12
|
+
* - `poll` returns the lines appended since a cursor, so the dialog renders a
|
|
13
|
+
* live log without a websocket of its own.
|
|
14
|
+
* - `cancel` kills the running child.
|
|
15
|
+
*
|
|
16
|
+
* Two knowledge points shape the flow. `codegraph install --yes` skips its own
|
|
17
|
+
* interactive "install the CLI on your PATH?" step, so the CLI install is done
|
|
18
|
+
* here with npm before the wiring runs. And the published CLI (1.6.2) predates
|
|
19
|
+
* the `dsh` target, so when `codegraph install --print-config dsh` fails the
|
|
20
|
+
* patch row is written directly in the shape that target would have produced.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the Typert protocol package.
|
|
30
|
+
*
|
|
31
|
+
* A bundle installed from a packed tarball resolves it by bare specifier. A
|
|
32
|
+
* linked checkout outside the profile directory does not: DSH routes
|
|
33
|
+
* `@deepseek-ai/*` requests only for modules that live under the profile, so
|
|
34
|
+
* the bare import fails there. The fallback resolves the same package from the
|
|
35
|
+
* running installation, which keeps `link:` development working without
|
|
36
|
+
* changing anything for a published install.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<object>} the protocol module namespace.
|
|
39
|
+
*/
|
|
40
|
+
async function importTypertProtocol() {
|
|
41
|
+
try {
|
|
42
|
+
return await import("@deepseek-ai/dsh-typert-protocol");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
for (const scopeDir of installedScopeDirs()) {
|
|
45
|
+
const entry = path.join(scopeDir, "dsh-typert-protocol", "lib", "index.js");
|
|
46
|
+
try {
|
|
47
|
+
await access(entry);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return await import(pathToFileURL(entry).href);
|
|
53
|
+
} catch {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Candidate `node_modules/@deepseek-ai` directories of the running DSH.
|
|
63
|
+
*
|
|
64
|
+
* The host process is started as `dsh <install-root> <profile> ...`, where the
|
|
65
|
+
* install root holds `node_modules/@deepseek-ai`, and Electron's `argv[1]` is a
|
|
66
|
+
* file inside that same tree. Reading them is enough to find the protocol
|
|
67
|
+
* package without depending on an environment variable DSH does not export to
|
|
68
|
+
* plugins.
|
|
69
|
+
*
|
|
70
|
+
* @returns {string[]} existing-looking scope directories, best candidate first.
|
|
71
|
+
*/
|
|
72
|
+
function installedScopeDirs() {
|
|
73
|
+
const scope = path.join("node_modules", "@deepseek-ai");
|
|
74
|
+
const candidates = [];
|
|
75
|
+
const fromEnv = process.env.DSH_INSTALL_DIR;
|
|
76
|
+
if (fromEnv !== undefined) candidates.push(path.dirname(fromEnv));
|
|
77
|
+
const marker = path.sep + scope;
|
|
78
|
+
for (const argument of process.argv.slice(1)) {
|
|
79
|
+
if (typeof argument !== "string") continue;
|
|
80
|
+
const at = argument.indexOf(marker);
|
|
81
|
+
if (at > 0) candidates.push(argument.slice(0, at) + marker);
|
|
82
|
+
candidates.push(path.join(argument, scope));
|
|
83
|
+
}
|
|
84
|
+
return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { Remote, RemoteError, TypertRemoteService } = await importTypertProtocol();
|
|
88
|
+
|
|
89
|
+
/** Wire namespace; every endpoint is `codegraphInstall/<method>`. */
|
|
90
|
+
const SERVICE = "codegraphInstall";
|
|
91
|
+
|
|
92
|
+
/** The npm package whose CLI provides the MCP server. */
|
|
93
|
+
const NPM_PACKAGE = "@colbymchenry/codegraph";
|
|
94
|
+
|
|
95
|
+
/** MCP server name; this is also the tool prefix the model sees. */
|
|
96
|
+
const SERVER_NAME = "codegraph";
|
|
97
|
+
|
|
98
|
+
/** The MCP client bundle every row points at. */
|
|
99
|
+
const MCP_CLIENT_NAME = "@deepseek-ai/dsh-mcp-client";
|
|
100
|
+
|
|
101
|
+
/** DSH reads its plugin patches from these two file names. */
|
|
102
|
+
const PATCH_FILE = "cordis.patch.yml";
|
|
103
|
+
const INSTRUCTIONS_FILE = "AGENTS.md";
|
|
104
|
+
|
|
105
|
+
/** The installer's own marker fence, reused so both writers stay interchangeable. */
|
|
106
|
+
const SECTION_START = "<!-- CODEGRAPH_START -->";
|
|
107
|
+
const SECTION_END = "<!-- CODEGRAPH_END -->";
|
|
108
|
+
|
|
109
|
+
/** Recognises a row that already serves this MCP server, in either file. */
|
|
110
|
+
const HAS_SERVER = /(^|[\s{,])["']?serverName["']?\s*:\s*["']?codegraph["']?\s*([,}#]|$)/mu;
|
|
111
|
+
|
|
112
|
+
/** Recognises the MCP client bundle inside a row block. */
|
|
113
|
+
const IS_MCP_CLIENT = /(^|[\s{,])["']?name["']?\s*:\s*["']?@deepseek-ai\/dsh-mcp-client["']?/mu;
|
|
114
|
+
|
|
115
|
+
/** Recognises our own row id, the one the CLI's `dsh` target writes. */
|
|
116
|
+
const IS_ROW_ID = /(^|[\s{,])["']?id["']?\s*:\s*["']?mcp-codegraph["']?\s*([,}#]|$)/mu;
|
|
117
|
+
|
|
118
|
+
/** Bounded per-run log; a wedged install cannot grow the host heap. */
|
|
119
|
+
const RUN_LINE_LIMIT = 4000;
|
|
120
|
+
|
|
121
|
+
/** Runs older than this are dropped from the map on the next start. */
|
|
122
|
+
const RUN_RETENTION_MS = 30 * 60 * 1000;
|
|
123
|
+
|
|
124
|
+
/** The argument vector the MCP server is started with. */
|
|
125
|
+
const MCP_ARGS = ["serve", "--mcp"];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The instructions block, byte-identical to `codegraph`'s own installer so a
|
|
129
|
+
* later `codegraph install -t dsh` rewrites it instead of appending a second.
|
|
130
|
+
*/
|
|
131
|
+
const INSTRUCTIONS_BLOCK = `${SECTION_START}
|
|
132
|
+
## CodeGraph
|
|
133
|
+
|
|
134
|
+
In repositories indexed by CodeGraph (a \`.codegraph/\` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
|
135
|
+
|
|
136
|
+
- **MCP tool** (when available): \`codegraph_explore\` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
|
|
137
|
+
- **Shell** (always works): \`codegraph explore "<symbol names or question>"\` prints the same output.
|
|
138
|
+
|
|
139
|
+
If there is no \`.codegraph/\` directory, skip CodeGraph entirely — indexing is the user's decision.
|
|
140
|
+
${SECTION_END}`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Declare one direct Remote method without decorator syntax: this runtime has
|
|
144
|
+
* V8 decorators switched off, so `@Remote(...)` is a hard SyntaxError here.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} Proto - Class carrying the method.
|
|
147
|
+
* @param {string} method - Method name, which is also the wire export name.
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
function declareRemote(Proto, method) {
|
|
151
|
+
const initializers = [];
|
|
152
|
+
Remote(method)(void 0, {
|
|
153
|
+
name: method,
|
|
154
|
+
"static": false,
|
|
155
|
+
private: false,
|
|
156
|
+
addInitializer(initializer) {
|
|
157
|
+
initializers.push(initializer);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
if (initializers.length !== 1) {
|
|
161
|
+
throw new Error(`codegraph-init: cannot declare Remote method '${method}'`);
|
|
162
|
+
}
|
|
163
|
+
initializers[0].call(Object.create(Proto.prototype));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* This DSH home, honouring the same `DSH_HOME` override the Host itself uses.
|
|
168
|
+
*
|
|
169
|
+
* @returns {string} absolute path of the harness home.
|
|
170
|
+
*/
|
|
171
|
+
function dshHome() {
|
|
172
|
+
const value = process.env.DSH_HOME?.trim();
|
|
173
|
+
if (!value) return path.join(os.homedir(), ".dsh");
|
|
174
|
+
const inHome = value === "~" || value.startsWith("~/");
|
|
175
|
+
return path.resolve(inHome ? path.join(os.homedir(), value.slice(1)) : value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read a text file, mapping "absent" onto `null` instead of throwing.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} file - Absolute path.
|
|
182
|
+
* @returns {Promise<string | null>} the contents, or null when missing.
|
|
183
|
+
*/
|
|
184
|
+
async function readText(file) {
|
|
185
|
+
try {
|
|
186
|
+
return await readFile(file, "utf8");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.code === "ENOENT") return null;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run one shell command line and collect its output without touching any run
|
|
195
|
+
* buffer. Login shell semantics matter here: a DSH launched from Finder gets
|
|
196
|
+
* the launchd PATH, and the user's node bin lives on the profile PATH only.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} command - Command line handed to `/bin/sh -lc`.
|
|
199
|
+
* @param {{timeoutMs?: number}} [options] - Kill the child after this long.
|
|
200
|
+
* @returns {Promise<{code: number, out: string, err: string}>} exit code and both streams.
|
|
201
|
+
*/
|
|
202
|
+
function execCapture(command, options = {}) {
|
|
203
|
+
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
let child;
|
|
206
|
+
try {
|
|
207
|
+
child = spawn("/bin/sh", ["-lc", command], {
|
|
208
|
+
env: { ...process.env },
|
|
209
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
resolve({ code: -1, out: "", err: String(error) });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let out = "";
|
|
216
|
+
let err = "";
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
try {
|
|
219
|
+
child.kill("SIGKILL");
|
|
220
|
+
} catch {}
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
child.stdout.on("data", (chunk) => {
|
|
223
|
+
out += String(chunk);
|
|
224
|
+
});
|
|
225
|
+
child.stderr.on("data", (chunk) => {
|
|
226
|
+
err += String(chunk);
|
|
227
|
+
});
|
|
228
|
+
child.on("error", (error) => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
resolve({ code: -1, out, err: String(error) });
|
|
231
|
+
});
|
|
232
|
+
child.on("close", (code) => {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
resolve({ code: code ?? -1, out, err });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Locate the codegraph CLI, its version and the npm that would install it.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<{found: boolean, path: string | null, version: string | null, npm: string | null}>} the probe.
|
|
243
|
+
*/
|
|
244
|
+
async function probeCli() {
|
|
245
|
+
const npm = await execCapture("command -v npm");
|
|
246
|
+
const npmPath = npm.code === 0 ? npm.out.trim().split("\n")[0] : null;
|
|
247
|
+
const located = await execCapture("command -v codegraph");
|
|
248
|
+
const found = located.code === 0 && located.out.trim() !== "";
|
|
249
|
+
if (!found) return { found: false, path: null, version: null, npm: npmPath === "" ? null : npmPath };
|
|
250
|
+
const cliPath = located.out.trim().split("\n")[0];
|
|
251
|
+
const version = await execCapture("codegraph --version");
|
|
252
|
+
const text = `${version.out}\n${version.err}`.trim();
|
|
253
|
+
const match = /\d+\.\d+\.\d+(?:[-+][\w.]+)?/u.exec(text);
|
|
254
|
+
return {
|
|
255
|
+
found: true,
|
|
256
|
+
path: cliPath.startsWith("/") ? cliPath : null,
|
|
257
|
+
version: match ? match[0] : text.split("\n")[0] ?? null,
|
|
258
|
+
npm: npmPath === "" ? null : npmPath
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Ask the installed CLI whether it ships the `dsh` installer target.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<boolean>} whether `codegraph install -t dsh` is understood.
|
|
266
|
+
*/
|
|
267
|
+
async function supportsDshTarget() {
|
|
268
|
+
const probe = await execCapture("codegraph install --print-config dsh", { timeoutMs: 30_000 });
|
|
269
|
+
return probe.code === 0 && /serverName/u.test(probe.out);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether any patch file — this home's or a profile's — already serves the
|
|
274
|
+
* `codegraph` MCP server.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} home - Absolute DSH home.
|
|
277
|
+
* @returns {Promise<object>} the wiring snapshot the dialog renders.
|
|
278
|
+
*/
|
|
279
|
+
async function wiringState(home) {
|
|
280
|
+
const homePatch = path.join(home, PATCH_FILE);
|
|
281
|
+
const homeText = await readText(homePatch);
|
|
282
|
+
const profilesDir = path.join(home, "profiles");
|
|
283
|
+
const profiles = [];
|
|
284
|
+
let entries = [];
|
|
285
|
+
try {
|
|
286
|
+
entries = await readdir(profilesDir, { withFileTypes: true });
|
|
287
|
+
} catch {}
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (!entry.isDirectory()) continue;
|
|
290
|
+
const patchPath = path.join(profilesDir, entry.name, PATCH_FILE);
|
|
291
|
+
const text = await readText(patchPath);
|
|
292
|
+
profiles.push({ name: entry.name, patchPath, configured: text !== null && HAS_SERVER.test(text) });
|
|
293
|
+
}
|
|
294
|
+
const instructionsPath = path.join(home, INSTRUCTIONS_FILE);
|
|
295
|
+
const instructionsText = await readText(instructionsPath);
|
|
296
|
+
const homeConfigured = homeText !== null && HAS_SERVER.test(homeText);
|
|
297
|
+
return {
|
|
298
|
+
home: { patchPath: homePatch, exists: homeText !== null, configured: homeConfigured },
|
|
299
|
+
profiles,
|
|
300
|
+
instructions: { path: instructionsPath, present: instructionsText !== null && instructionsText.includes(SECTION_START) },
|
|
301
|
+
configured: homeConfigured || profiles.some((entry) => entry.configured)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Split a YAML file into lines while remembering its line ending.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} text - File contents.
|
|
309
|
+
* @returns {{lines: string[], eol: string}} lines without terminators.
|
|
310
|
+
*/
|
|
311
|
+
function splitLines(text) {
|
|
312
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
313
|
+
const lines = text.split(/\r?\n/u);
|
|
314
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
315
|
+
return { lines, eol };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Render lines back into file contents, always ending with one newline.
|
|
320
|
+
*
|
|
321
|
+
* @param {string[]} lines - Body lines.
|
|
322
|
+
* @param {string} eol - Line ending to use.
|
|
323
|
+
* @returns {string} file contents.
|
|
324
|
+
*/
|
|
325
|
+
function joinLines(lines, eol) {
|
|
326
|
+
return lines.length === 0 ? "" : `${lines.join(eol)}${eol}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Blank lines, YAML document markers and full-line comments.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} line - One raw line.
|
|
333
|
+
* @returns {boolean} whether the line carries no structure.
|
|
334
|
+
*/
|
|
335
|
+
function isTrivia(line) {
|
|
336
|
+
const text = line.trim();
|
|
337
|
+
return text === "" || text === "---" || text.startsWith("#");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A top-level list item, which is the only shape the patch loader accepts. */
|
|
341
|
+
function isTopLevelItem(line) {
|
|
342
|
+
return /^-(\s|$)/u.test(line);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Classify the file's structure: empty (no items), a top-level block list, or
|
|
347
|
+
* something this writer must not touch (flow-style `[]`, a mapping, JSON).
|
|
348
|
+
*
|
|
349
|
+
* @param {string[]} lines - Body lines.
|
|
350
|
+
* @returns {{kind: "empty" | "list" | "other", emptyList: number}} the layout.
|
|
351
|
+
*/
|
|
352
|
+
function layoutOf(lines) {
|
|
353
|
+
const content = [];
|
|
354
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
355
|
+
if (!isTrivia(lines[index])) content.push(index);
|
|
356
|
+
}
|
|
357
|
+
if (content.length === 0) return { kind: "empty", emptyList: -1 };
|
|
358
|
+
if (content.length === 1 && /^\[\s*\]\s*(#.*)?$/u.test(lines[content[0]])) return { kind: "empty", emptyList: content[0] };
|
|
359
|
+
const first = lines[content[0]];
|
|
360
|
+
const everyLineListed = content.every((index) => /^\s/u.test(lines[index]) || isTopLevelItem(lines[index]));
|
|
361
|
+
return { kind: isTopLevelItem(first) && everyLineListed ? "list" : "other", emptyList: -1 };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The row this plugin owns, in the exact shape `codegraph`'s `dsh` target
|
|
366
|
+
* writes it — except `command`, which is pinned to the CLI's absolute path so
|
|
367
|
+
* the MCP server also starts when DSH inherits a PATH without the node bin.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} command - Executable the MCP client spawns.
|
|
370
|
+
* @returns {string[]} the row, `- insert:` wrapper included, unindented.
|
|
371
|
+
*/
|
|
372
|
+
function serverRowLines(command) {
|
|
373
|
+
return [
|
|
374
|
+
"- insert:",
|
|
375
|
+
" - id: mcp-codegraph",
|
|
376
|
+
` name: '${MCP_CLIENT_NAME}'`,
|
|
377
|
+
" config:",
|
|
378
|
+
" serverName: codegraph",
|
|
379
|
+
" transport: stdio",
|
|
380
|
+
` command: ${command}`,
|
|
381
|
+
" args:",
|
|
382
|
+
...MCP_ARGS.map((arg) => ` - ${arg}`)
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Add the MCP row to a patch file body, refusing layouts the loader rejects.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} text - Current file contents.
|
|
390
|
+
* @param {string} command - Executable to pin.
|
|
391
|
+
* @returns {{text: string, action: "created" | "inserted" | "unchanged"}} the edit.
|
|
392
|
+
*/
|
|
393
|
+
function insertServerRow(text, command) {
|
|
394
|
+
const { lines, eol } = splitLines(text);
|
|
395
|
+
if (HAS_SERVER.test(text)) return { text: joinLines(lines, eol), action: "unchanged" };
|
|
396
|
+
const layout = layoutOf(lines);
|
|
397
|
+
if (layout.kind === "other") {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`${PATCH_FILE} is not a top-level YAML list, so the codegraph row cannot be appended safely; add it by hand: ${path.join("$DSH_HOME", PATCH_FILE)}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const row = serverRowLines(command);
|
|
403
|
+
if (layout.kind === "empty") {
|
|
404
|
+
const body = layout.emptyList >= 0 ? lines.filter((_, index) => index !== layout.emptyList) : lines;
|
|
405
|
+
const kept = body.filter((line) => line.trim() !== "");
|
|
406
|
+
return { text: joinLines([...(kept.length === 0 ? [] : [...kept, ""]), ...row], eol), action: "created" };
|
|
407
|
+
}
|
|
408
|
+
const body = [...lines];
|
|
409
|
+
while (body.length > 0 && body[body.length - 1].trim() === "") body.pop();
|
|
410
|
+
return { text: joinLines([...body, "", ...row], eol), action: "inserted" };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A block parent that only introduces a nested list, such as `- insert:`.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} line - One raw line.
|
|
417
|
+
* @returns {boolean} whether the line opens a child list instead of holding data.
|
|
418
|
+
*/
|
|
419
|
+
function isContainerLine(line) {
|
|
420
|
+
return /^\s*-\s*[A-Za-z0-9_-]+\s*:\s*$/u.test(line);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Child items of one container block, at the children's own indentation.
|
|
425
|
+
*
|
|
426
|
+
* Deeper `- ` lines belong to a child's own value (an `args:` list), so they
|
|
427
|
+
* must not read as siblings — that mistake left half a row behind.
|
|
428
|
+
*
|
|
429
|
+
* @param {string[]} lines - Body lines.
|
|
430
|
+
* @param {number} from - First line of the block (the container item).
|
|
431
|
+
* @param {number} to - Last line of the block.
|
|
432
|
+
* @returns {number[][]} one `[start, end]` span per child item.
|
|
433
|
+
*/
|
|
434
|
+
function blockChildren(lines, from, to) {
|
|
435
|
+
let indent = Number.POSITIVE_INFINITY;
|
|
436
|
+
for (let index = from + 1; index <= to; index += 1) {
|
|
437
|
+
const match = /^(\s+)-(\s|$)/u.exec(lines[index]);
|
|
438
|
+
if (match === null) continue;
|
|
439
|
+
if (match[1].length < indent) indent = match[1].length;
|
|
440
|
+
}
|
|
441
|
+
if (!Number.isFinite(indent)) return [];
|
|
442
|
+
const spans = [];
|
|
443
|
+
let start = -1;
|
|
444
|
+
for (let index = from + 1; index <= to; index += 1) {
|
|
445
|
+
const match = /^(\s+)-(\s|$)/u.exec(lines[index]);
|
|
446
|
+
const isSibling = match !== null && match[1].length === indent;
|
|
447
|
+
if (!isSibling) continue;
|
|
448
|
+
if (start >= 0) spans.push([start, index - 1]);
|
|
449
|
+
start = index;
|
|
450
|
+
}
|
|
451
|
+
if (start >= 0) spans.push([start, to]);
|
|
452
|
+
return spans;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Whether one block of YAML is a `codegraph` MCP row.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} text - Block text, newline joined.
|
|
459
|
+
* @returns {boolean} whether removing this block removes the server.
|
|
460
|
+
*/
|
|
461
|
+
function isOurRow(text) {
|
|
462
|
+
return HAS_SERVER.test(text) || (IS_ROW_ID.test(text) && IS_MCP_CLIENT.test(text));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Remove every `codegraph` MCP row from a patch file body.
|
|
467
|
+
*
|
|
468
|
+
* Two shapes reach here: the CLI's own `- insert:` container holding our row as
|
|
469
|
+
* a child, and a row written flat at the top level. A container loses only its
|
|
470
|
+
* matching children — and the container itself once it has none left — so an
|
|
471
|
+
* unrelated insert block in the same file survives.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} text - Current file contents.
|
|
474
|
+
* @returns {{text: string, action: "removed" | "absent"}} the edit.
|
|
475
|
+
*/
|
|
476
|
+
function removeServerRows(text) {
|
|
477
|
+
const { lines, eol } = splitLines(text);
|
|
478
|
+
if (!HAS_SERVER.test(text) && !IS_ROW_ID.test(text)) return { text: joinLines(lines, eol), action: "absent" };
|
|
479
|
+
const doomed = new Set();
|
|
480
|
+
const drop = (from, to) => {
|
|
481
|
+
for (let line = from; line <= to; line += 1) doomed.add(line);
|
|
482
|
+
};
|
|
483
|
+
let blockStart = -1;
|
|
484
|
+
for (let index = 0; index <= lines.length; index += 1) {
|
|
485
|
+
const atBoundary = index === lines.length || isTopLevelItem(lines[index]);
|
|
486
|
+
if (!atBoundary) continue;
|
|
487
|
+
if (blockStart >= 0) {
|
|
488
|
+
let end = index - 1;
|
|
489
|
+
while (end > blockStart && isTrivia(lines[end])) end -= 1;
|
|
490
|
+
if (isContainerLine(lines[blockStart])) {
|
|
491
|
+
const children = blockChildren(lines, blockStart, end);
|
|
492
|
+
const removed = children.filter(([from, to]) => isOurRow(lines.slice(from, to + 1).join("\n")));
|
|
493
|
+
for (const [from, to] of removed) drop(from, to);
|
|
494
|
+
if (children.length > 0 && removed.length === children.length) drop(blockStart, end);
|
|
495
|
+
} else if (isOurRow(lines.slice(blockStart, end + 1).join("\n"))) {
|
|
496
|
+
drop(blockStart, end);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
blockStart = index;
|
|
500
|
+
}
|
|
501
|
+
const kept = lines.filter((_, index) => !doomed.has(index));
|
|
502
|
+
const collapsed = [];
|
|
503
|
+
for (const line of kept) {
|
|
504
|
+
if (line.trim() === "" && (collapsed.length === 0 || collapsed[collapsed.length - 1].trim() === "")) continue;
|
|
505
|
+
collapsed.push(line);
|
|
506
|
+
}
|
|
507
|
+
while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === "") collapsed.pop();
|
|
508
|
+
return { text: joinLines(collapsed, eol), action: "removed" };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Upsert the marker-fenced instructions block.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} text - Current file contents.
|
|
515
|
+
* @returns {{text: string, action: "created" | "updated" | "unchanged"}} the edit.
|
|
516
|
+
*/
|
|
517
|
+
function upsertInstructions(text) {
|
|
518
|
+
const start = text.indexOf(SECTION_START);
|
|
519
|
+
const end = text.indexOf(SECTION_END);
|
|
520
|
+
if (start >= 0 && end > start) {
|
|
521
|
+
const next = `${text.slice(0, start)}${INSTRUCTIONS_BLOCK}${text.slice(end + SECTION_END.length)}`;
|
|
522
|
+
return { text: next, action: next === text ? "unchanged" : "updated" };
|
|
523
|
+
}
|
|
524
|
+
const body = text.replace(/\s*$/u, "");
|
|
525
|
+
const next = body === "" ? `${INSTRUCTIONS_BLOCK}\n` : `${body}\n\n${INSTRUCTIONS_BLOCK}\n`;
|
|
526
|
+
return { text: next, action: "created" };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Strip the marker-fenced instructions block.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} text - Current file contents.
|
|
533
|
+
* @returns {{text: string, action: "removed" | "absent"}} the edit.
|
|
534
|
+
*/
|
|
535
|
+
function removeInstructions(text) {
|
|
536
|
+
const start = text.indexOf(SECTION_START);
|
|
537
|
+
const end = text.indexOf(SECTION_END);
|
|
538
|
+
if (start < 0 || end < start) return { text, action: "absent" };
|
|
539
|
+
const before = text.slice(0, start).replace(/\s+$/u, "");
|
|
540
|
+
const after = text.slice(end + SECTION_END.length).replace(/^\s+/u, "");
|
|
541
|
+
let next = "";
|
|
542
|
+
if (before !== "" && after !== "") next = `${before}\n\n${after}`;
|
|
543
|
+
else if (before !== "") next = `${before}\n`;
|
|
544
|
+
else if (after !== "") next = `${after}\n`;
|
|
545
|
+
return { text: next, action: "removed" };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Append one line to a run's bounded buffer.
|
|
550
|
+
*
|
|
551
|
+
* @param {object} run - Run record.
|
|
552
|
+
* @param {"step" | "out" | "err" | "ok" | "note"} kind - Line class the dialog styles.
|
|
553
|
+
* @param {string} text - One line, ideally without a terminator.
|
|
554
|
+
* @returns {void}
|
|
555
|
+
*/
|
|
556
|
+
function pushLine(run, kind, text) {
|
|
557
|
+
run.seq += 1;
|
|
558
|
+
run.lines.push({ seq: run.seq, kind, text });
|
|
559
|
+
if (run.lines.length > RUN_LINE_LIMIT) {
|
|
560
|
+
const dropped = run.lines.length - RUN_LINE_LIMIT;
|
|
561
|
+
run.lines.splice(0, dropped);
|
|
562
|
+
run.dropped += dropped;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Stream one command into the run buffer until it exits.
|
|
568
|
+
*
|
|
569
|
+
* @param {object} run - Run record owning the log.
|
|
570
|
+
* @param {string} command - Command line handed to `/bin/sh -lc`.
|
|
571
|
+
* @param {{timeoutMs?: number}} [options] - Kill the child after this long.
|
|
572
|
+
* @returns {Promise<number>} the exit code.
|
|
573
|
+
*/
|
|
574
|
+
function runStreaming(run, command, options = {}) {
|
|
575
|
+
const timeoutMs = options.timeoutMs ?? 15 * 60 * 1000;
|
|
576
|
+
pushLine(run, "step", `$ ${command}`);
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
let child;
|
|
579
|
+
try {
|
|
580
|
+
child = spawn("/bin/sh", ["-lc", command], { env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"] });
|
|
581
|
+
} catch (error) {
|
|
582
|
+
pushLine(run, "err", String(error));
|
|
583
|
+
resolve(-1);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
run.child = child;
|
|
587
|
+
const timer = setTimeout(() => {
|
|
588
|
+
pushLine(run, "err", `超时(${Math.round(timeoutMs / 1000)}s),已终止该命令`);
|
|
589
|
+
try {
|
|
590
|
+
child.kill("SIGKILL");
|
|
591
|
+
} catch {}
|
|
592
|
+
}, timeoutMs);
|
|
593
|
+
const feed = (kind) => (chunk) => {
|
|
594
|
+
for (const line of String(chunk).split(/\r?\n/u)) {
|
|
595
|
+
if (line.trim() === "") continue;
|
|
596
|
+
pushLine(run, kind, line);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
child.stdout.on("data", feed("out"));
|
|
600
|
+
child.stderr.on("data", feed("err"));
|
|
601
|
+
child.on("error", (error) => {
|
|
602
|
+
clearTimeout(timer);
|
|
603
|
+
run.child = null;
|
|
604
|
+
pushLine(run, "err", String(error));
|
|
605
|
+
resolve(-1);
|
|
606
|
+
});
|
|
607
|
+
child.on("close", (code) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
run.child = null;
|
|
610
|
+
resolve(code ?? -1);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The codegraph wiring service.
|
|
617
|
+
*/
|
|
618
|
+
class CodegraphInstallService extends TypertRemoteService {
|
|
619
|
+
/** Nothing below may run before the Host context exists. */
|
|
620
|
+
static inject = [];
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* @param {object} ctx - Host context.
|
|
624
|
+
*/
|
|
625
|
+
constructor(ctx) {
|
|
626
|
+
super(ctx, SERVICE);
|
|
627
|
+
this.runs = new Map();
|
|
628
|
+
this.activeRunId = null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Report the CLI and the current wiring; the dialog asks once per open.
|
|
633
|
+
*
|
|
634
|
+
* @param {object} request - Unused; kept so every endpoint takes one object.
|
|
635
|
+
* @returns {Promise<object>} the status the dialog renders before a run.
|
|
636
|
+
*/
|
|
637
|
+
async status(request) {
|
|
638
|
+
void request;
|
|
639
|
+
const home = dshHome();
|
|
640
|
+
const cli = await probeCli();
|
|
641
|
+
const wiring = await wiringState(home);
|
|
642
|
+
const supports = cli.found ? await supportsDshTarget() : false;
|
|
643
|
+
return {
|
|
644
|
+
dshHome: home,
|
|
645
|
+
serverName: SERVER_NAME,
|
|
646
|
+
cli,
|
|
647
|
+
wiring,
|
|
648
|
+
cliSupportsDshTarget: supports,
|
|
649
|
+
activeRunId: this.activeRunId
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Launch one install or uninstall run; the dialog polls for the log.
|
|
655
|
+
*
|
|
656
|
+
* @param {{action?: string, removeCli?: boolean}} request - What to run.
|
|
657
|
+
* @returns {Promise<{runId: string}>} the id to poll.
|
|
658
|
+
*/
|
|
659
|
+
async start(request) {
|
|
660
|
+
const action = request?.action;
|
|
661
|
+
if (action !== "install" && action !== "uninstall") {
|
|
662
|
+
throw new RemoteError("codegraph/invalid-request", "action must be 'install' or 'uninstall'", { action: action ?? null });
|
|
663
|
+
}
|
|
664
|
+
const active = this.activeRunId === null ? null : this.runs.get(this.activeRunId);
|
|
665
|
+
if (active !== undefined && active !== null && !active.done) {
|
|
666
|
+
throw new RemoteError("codegraph/busy", "another codegraph run is still in progress", { runId: active.id });
|
|
667
|
+
}
|
|
668
|
+
this.pruneRuns();
|
|
669
|
+
const run = {
|
|
670
|
+
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
671
|
+
action,
|
|
672
|
+
removeCli: request?.removeCli === true,
|
|
673
|
+
lines: [],
|
|
674
|
+
seq: 0,
|
|
675
|
+
dropped: 0,
|
|
676
|
+
done: false,
|
|
677
|
+
cancelled: false,
|
|
678
|
+
ok: null,
|
|
679
|
+
error: null,
|
|
680
|
+
child: null,
|
|
681
|
+
startedAt: Date.now(),
|
|
682
|
+
finishedAt: null
|
|
683
|
+
};
|
|
684
|
+
this.runs.set(run.id, run);
|
|
685
|
+
this.activeRunId = run.id;
|
|
686
|
+
void this.execute(run).catch((error) => {
|
|
687
|
+
run.ok = false;
|
|
688
|
+
run.error = error instanceof Error ? error.message : String(error);
|
|
689
|
+
pushLine(run, "err", run.error);
|
|
690
|
+
run.done = true;
|
|
691
|
+
run.finishedAt = Date.now();
|
|
692
|
+
this.ctx.logger?.warn?.(`codegraph-init: run failed: ${run.error}`);
|
|
693
|
+
});
|
|
694
|
+
return { runId: run.id };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Read the lines appended after a cursor.
|
|
699
|
+
*
|
|
700
|
+
* @param {{runId?: string, after?: number}} request - Cursor.
|
|
701
|
+
* @returns {Promise<object>} appended lines plus the run's outcome.
|
|
702
|
+
*/
|
|
703
|
+
async poll(request) {
|
|
704
|
+
const run = this.runs.get(request?.runId);
|
|
705
|
+
if (run === undefined) {
|
|
706
|
+
throw new RemoteError("codegraph/not-found", "no such codegraph run; it may predate a Host restart", { runId: request?.runId ?? null });
|
|
707
|
+
}
|
|
708
|
+
const after = Number.isFinite(request?.after) ? Number(request.after) : 0;
|
|
709
|
+
return {
|
|
710
|
+
runId: run.id,
|
|
711
|
+
action: run.action,
|
|
712
|
+
lines: run.lines.filter((line) => line.seq > after),
|
|
713
|
+
next: run.seq,
|
|
714
|
+
dropped: run.dropped,
|
|
715
|
+
done: run.done,
|
|
716
|
+
ok: run.ok,
|
|
717
|
+
error: run.error,
|
|
718
|
+
active: this.activeRunId === run.id
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Kill the run's child process, if it still has one.
|
|
724
|
+
*
|
|
725
|
+
* @param {{runId?: string}} request - Which run.
|
|
726
|
+
* @returns {Promise<{runId: string, cancelled: boolean}>} the outcome.
|
|
727
|
+
*/
|
|
728
|
+
async cancel(request) {
|
|
729
|
+
const run = this.runs.get(request?.runId);
|
|
730
|
+
if (run === undefined) {
|
|
731
|
+
throw new RemoteError("codegraph/not-found", "no such codegraph run", { runId: request?.runId ?? null });
|
|
732
|
+
}
|
|
733
|
+
const child = run.child;
|
|
734
|
+
if (child !== null) {
|
|
735
|
+
pushLine(run, "err", "已取消,正在终止当前命令");
|
|
736
|
+
try {
|
|
737
|
+
child.kill("SIGTERM");
|
|
738
|
+
} catch {}
|
|
739
|
+
}
|
|
740
|
+
run.cancelled = true;
|
|
741
|
+
run.finishedAt = run.finishedAt ?? Date.now();
|
|
742
|
+
run.done = true;
|
|
743
|
+
run.ok = false;
|
|
744
|
+
run.error = run.error ?? "cancelled";
|
|
745
|
+
return { runId: run.id, cancelled: child !== null };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Drop a finished run from the map so its lines stop occupying memory.
|
|
750
|
+
*
|
|
751
|
+
* @param {{runId?: string}} request - Which run.
|
|
752
|
+
* @returns {Promise<{runId: string, forgotten: boolean}>} the outcome.
|
|
753
|
+
*/
|
|
754
|
+
async forget(request) {
|
|
755
|
+
const id = request?.runId;
|
|
756
|
+
const run = this.runs.get(id);
|
|
757
|
+
if (run === undefined) return { runId: id ?? null, forgotten: false };
|
|
758
|
+
if (!run.done) return { runId: run.id, forgotten: false };
|
|
759
|
+
this.runs.delete(run.id);
|
|
760
|
+
if (this.activeRunId === run.id) this.activeRunId = null;
|
|
761
|
+
return { runId: run.id, forgotten: true };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Discard runs nobody can poll any more.
|
|
766
|
+
*
|
|
767
|
+
* @returns {void}
|
|
768
|
+
*/
|
|
769
|
+
pruneRuns() {
|
|
770
|
+
const cutoff = Date.now() - RUN_RETENTION_MS;
|
|
771
|
+
for (const [id, run] of this.runs) {
|
|
772
|
+
if (run.done && (run.finishedAt ?? 0) < cutoff) this.runs.delete(id);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Drive one run to completion, streaming each step into its buffer.
|
|
778
|
+
*
|
|
779
|
+
* @param {object} run - Run record from `start`.
|
|
780
|
+
* @returns {Promise<void>} resolution when the run finished.
|
|
781
|
+
*/
|
|
782
|
+
async execute(run) {
|
|
783
|
+
try {
|
|
784
|
+
if (run.action === "install") await this.runInstall(run);
|
|
785
|
+
else await this.runUninstall(run);
|
|
786
|
+
run.ok = !run.cancelled;
|
|
787
|
+
if (run.cancelled) run.error = run.error ?? "cancelled";
|
|
788
|
+
} catch (error) {
|
|
789
|
+
run.ok = false;
|
|
790
|
+
run.error = run.cancelled ? "cancelled" : error instanceof Error ? error.message : String(error);
|
|
791
|
+
pushLine(run, "err", run.error);
|
|
792
|
+
} finally {
|
|
793
|
+
run.done = true;
|
|
794
|
+
run.finishedAt = Date.now();
|
|
795
|
+
if (this.activeRunId === run.id) this.activeRunId = null;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Ensure the CLI, then wire one `codegraph` MCP server into this DSH home.
|
|
801
|
+
*
|
|
802
|
+
* @param {object} run - Run record receiving the log.
|
|
803
|
+
* @returns {Promise<void>} resolution when the wiring is verified.
|
|
804
|
+
*/
|
|
805
|
+
async runInstall(run) {
|
|
806
|
+
const home = dshHome();
|
|
807
|
+
pushLine(run, "step", "1/4 检查 codegraph CLI");
|
|
808
|
+
let cli = await probeCli();
|
|
809
|
+
if (!cli.npm) throw new Error("找不到 npm;请先让 npm 出现在 PATH 上,再重试");
|
|
810
|
+
if (!cli.found) {
|
|
811
|
+
pushLine(run, "note", "未找到 codegraph,开始安装 npm 包");
|
|
812
|
+
const code = await runStreaming(run, `npm install -g ${NPM_PACKAGE}@latest`, { timeoutMs: 15 * 60 * 1000 });
|
|
813
|
+
if (code !== 0) {
|
|
814
|
+
pushLine(run, "note", `若上面是 EACCES / EPERM,用 sudo 手工装一次再回来重试:sudo npm install -g ${NPM_PACKAGE}`);
|
|
815
|
+
throw new Error(`npm 安装失败(退出码 ${code}):${NPM_PACKAGE}`);
|
|
816
|
+
}
|
|
817
|
+
cli = await probeCli();
|
|
818
|
+
if (!cli.found) throw new Error("npm 报告安装成功,但 PATH 上仍然找不到 codegraph");
|
|
819
|
+
} else {
|
|
820
|
+
pushLine(run, "note", `已安装 codegraph ${cli.version ?? "(版本未知)"}:${cli.path ?? "PATH"}`);
|
|
821
|
+
}
|
|
822
|
+
const cliVersion = cli.version;
|
|
823
|
+
const cliPath = cli.path ?? SERVER_NAME;
|
|
824
|
+
|
|
825
|
+
pushLine(run, "step", "2/4 判断当前 CLI 是否认识 dsh 目标");
|
|
826
|
+
const supports = await supportsDshTarget();
|
|
827
|
+
let wiredByCli = false;
|
|
828
|
+
if (supports) {
|
|
829
|
+
pushLine(run, "note", "CLI 自带 dsh target,交给 codegraph install 处理");
|
|
830
|
+
const code = await runStreaming(run, "codegraph install -y -l global -t dsh", { timeoutMs: 5 * 60 * 1000 });
|
|
831
|
+
if (code === 0) {
|
|
832
|
+
const state = await wiringState(home);
|
|
833
|
+
wiredByCli = state.configured;
|
|
834
|
+
if (!wiredByCli) pushLine(run, "note", "codegraph install 退出成功,但没有写入 codegraph 行,改用插件内置写入");
|
|
835
|
+
} else {
|
|
836
|
+
pushLine(run, "note", `codegraph install 退出码 ${code},改用插件内置写入`);
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
pushLine(run, "note", `npm 版 codegraph ${cliVersion ?? ""} 还没有 dsh target(上游 2026-10-30 才加入),由插件直接写 DSH 接线`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!wiredByCli) {
|
|
843
|
+
pushLine(run, "step", "3/4 写入 DSH 的 MCP 接线");
|
|
844
|
+
await this.writeWiring(run, home, cliPath);
|
|
845
|
+
} else {
|
|
846
|
+
pushLine(run, "step", "3/4 写入 DSH 的 MCP 接线(由 CLI 完成)");
|
|
847
|
+
await this.writeInstructions(run, home);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
pushLine(run, "step", "4/4 校验");
|
|
851
|
+
const state = await wiringState(home);
|
|
852
|
+
if (!state.configured) throw new Error(`接线校验失败:${path.join(home, PATCH_FILE)} 里没有 codegraph 行`);
|
|
853
|
+
pushLine(run, "ok", `codegraph MCP 已接入:${state.home.patchPath}`);
|
|
854
|
+
pushLine(run, "note", "新开一个会话后生效(若 dsh-hmr 已关闭,需要重启 DSH)。");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Write the patch row and the instructions block directly.
|
|
859
|
+
*
|
|
860
|
+
* @param {object} run - Run record receiving the log.
|
|
861
|
+
* @param {string} home - Absolute DSH home.
|
|
862
|
+
* @param {string} command - Executable the MCP client must spawn.
|
|
863
|
+
* @returns {Promise<void>} resolution after both files are written.
|
|
864
|
+
*/
|
|
865
|
+
async writeWiring(run, home, command) {
|
|
866
|
+
const state = await wiringState(home);
|
|
867
|
+
const elsewhere = state.profiles.filter((entry) => entry.configured);
|
|
868
|
+
if (elsewhere.length > 0) {
|
|
869
|
+
pushLine(run, "note", `profile 里已有接线,跳过本 home 的写入:${elsewhere.map((entry) => entry.patchPath).join(", ")}`);
|
|
870
|
+
} else {
|
|
871
|
+
const patchPath = state.home.patchPath;
|
|
872
|
+
const current = (await readText(patchPath)) ?? "";
|
|
873
|
+
const edit = insertServerRow(current, command);
|
|
874
|
+
if (edit.action !== "unchanged") {
|
|
875
|
+
await mkdir(path.dirname(patchPath), { recursive: true });
|
|
876
|
+
await writeFile(patchPath, edit.text, "utf8");
|
|
877
|
+
pushLine(run, "note", `${edit.action === "created" ? "新建" : "追加"}接线:${patchPath}`);
|
|
878
|
+
} else {
|
|
879
|
+
pushLine(run, "note", `接线已存在:${patchPath}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
await this.writeInstructions(run, home);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Upsert the CodeGraph instructions block in `$DSH_HOME/AGENTS.md`.
|
|
887
|
+
*
|
|
888
|
+
* @param {object} run - Run record receiving the log.
|
|
889
|
+
* @param {string} home - Absolute DSH home.
|
|
890
|
+
* @returns {Promise<void>} resolution after the file is written.
|
|
891
|
+
*/
|
|
892
|
+
async writeInstructions(run, home) {
|
|
893
|
+
const file = path.join(home, INSTRUCTIONS_FILE);
|
|
894
|
+
const current = await readText(file);
|
|
895
|
+
if (current === null) {
|
|
896
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
897
|
+
await writeFile(file, `${INSTRUCTIONS_BLOCK}\n`, "utf8");
|
|
898
|
+
pushLine(run, "note", `新建说明文件:${file}`);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const edit = upsertInstructions(current);
|
|
902
|
+
if (edit.action === "unchanged") {
|
|
903
|
+
pushLine(run, "note", `说明块已存在:${file}`);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
await writeFile(file, edit.text, "utf8");
|
|
907
|
+
pushLine(run, "note", `${edit.action === "created" ? "追加" : "更新"}说明块:${file}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Remove the MCP row, then optionally the global CLI.
|
|
912
|
+
*
|
|
913
|
+
* @param {object} run - Run record receiving the log.
|
|
914
|
+
* @returns {Promise<void>} resolution when the wiring is gone.
|
|
915
|
+
*/
|
|
916
|
+
async runUninstall(run) {
|
|
917
|
+
const home = dshHome();
|
|
918
|
+
pushLine(run, "step", "1/3 检查 codegraph CLI");
|
|
919
|
+
const cli = await probeCli();
|
|
920
|
+
if (cli.found) pushLine(run, "note", `已安装 codegraph ${cli.version ?? "(版本未知)"}:${cli.path ?? "PATH"}`);
|
|
921
|
+
else pushLine(run, "note", "PATH 上没有 codegraph,只清理配置文件");
|
|
922
|
+
|
|
923
|
+
pushLine(run, "step", "2/3 移除 DSH 的 MCP 接线");
|
|
924
|
+
let removedByCli = false;
|
|
925
|
+
if (cli.found && (await supportsDshTarget())) {
|
|
926
|
+
const code = await runStreaming(run, "codegraph uninstall -y -l global -t dsh", { timeoutMs: 5 * 60 * 1000 });
|
|
927
|
+
const state = await wiringState(home);
|
|
928
|
+
removedByCli = code === 0 && !state.configured;
|
|
929
|
+
if (!removedByCli) pushLine(run, "note", "CLI 没有清干净(或该版本不含 dsh target),继续按插件内置方式清理");
|
|
930
|
+
}
|
|
931
|
+
if (!removedByCli) await this.clearWiring(run, home);
|
|
932
|
+
|
|
933
|
+
pushLine(run, "step", "3/3 校验");
|
|
934
|
+
const state = await wiringState(home);
|
|
935
|
+
if (state.configured) throw new Error("卸载校验失败:仍能找到 codegraph MCP 行");
|
|
936
|
+
pushLine(run, "ok", "codegraph MCP 接线已移除");
|
|
937
|
+
if (run.removeCli) {
|
|
938
|
+
if (!cli.found) {
|
|
939
|
+
pushLine(run, "note", "PATH 上本来就没有 codegraph,跳过 CLI 卸载");
|
|
940
|
+
} else {
|
|
941
|
+
pushLine(run, "step", `移除全局 CLI:npm uninstall -g ${NPM_PACKAGE}`);
|
|
942
|
+
const code = await runStreaming(run, `npm uninstall -g ${NPM_PACKAGE}`, { timeoutMs: 5 * 60 * 1000 });
|
|
943
|
+
if (code !== 0) pushLine(run, "note", `npm uninstall 退出码 ${code},CLI 可能仍在 PATH 上`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
pushLine(run, "note", "新开一个会话后生效(若 dsh-hmr 已关闭,需要重启 DSH)。");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Remove the patch rows and the instructions block from this home.
|
|
951
|
+
*
|
|
952
|
+
* @param {object} run - Run record receiving the log.
|
|
953
|
+
* @param {string} home - Absolute DSH home.
|
|
954
|
+
* @returns {Promise<void>} resolution after both files are edited.
|
|
955
|
+
*/
|
|
956
|
+
async clearWiring(run, home) {
|
|
957
|
+
const state = await wiringState(home);
|
|
958
|
+
if (state.home.configured) {
|
|
959
|
+
const patchPath = state.home.patchPath;
|
|
960
|
+
const current = (await readText(patchPath)) ?? "";
|
|
961
|
+
const edit = removeServerRows(current);
|
|
962
|
+
if (edit.action === "removed") {
|
|
963
|
+
await writeFile(patchPath, edit.text, "utf8");
|
|
964
|
+
pushLine(run, "note", `已移除接线:${patchPath}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
for (const profile of state.profiles) {
|
|
968
|
+
if (!profile.configured) continue;
|
|
969
|
+
const current = (await readText(profile.patchPath)) ?? "";
|
|
970
|
+
const edit = removeServerRows(current);
|
|
971
|
+
if (edit.action !== "removed") continue;
|
|
972
|
+
await writeFile(profile.patchPath, edit.text, "utf8");
|
|
973
|
+
pushLine(run, "note", `已移除 profile 接线:${profile.patchPath}`);
|
|
974
|
+
}
|
|
975
|
+
if (state.instructions.present) {
|
|
976
|
+
const file = state.instructions.path;
|
|
977
|
+
const current = (await readText(file)) ?? "";
|
|
978
|
+
const edit = removeInstructions(current);
|
|
979
|
+
if (edit.action === "removed") {
|
|
980
|
+
if (edit.text.trim() === "") {
|
|
981
|
+
await rm(file, { force: true });
|
|
982
|
+
pushLine(run, "note", `说明文件已空,删除:${file}`);
|
|
983
|
+
} else {
|
|
984
|
+
await writeFile(file, edit.text, "utf8");
|
|
985
|
+
pushLine(run, "note", `已移除说明块:${file}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
declareRemote(CodegraphInstallService, "status");
|
|
993
|
+
declareRemote(CodegraphInstallService, "start");
|
|
994
|
+
declareRemote(CodegraphInstallService, "poll");
|
|
995
|
+
declareRemote(CodegraphInstallService, "cancel");
|
|
996
|
+
declareRemote(CodegraphInstallService, "forget");
|
|
997
|
+
|
|
998
|
+
export { CodegraphInstallService, CodegraphInstallService as default };
|