@dong-victor/dsh-better-sidebar-starter 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/README.md +55 -0
- package/cordis.patch.yml +14 -0
- package/dsh.plugin.json +16 -0
- package/lib/client.js +2465 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1030 -0
- package/package.json +82 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
6
|
+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
7
|
+
//#region src/host/processManager.ts
|
|
8
|
+
/**
|
|
9
|
+
* Process manager: spawns child processes for run configurations, collects
|
|
10
|
+
* stdout/stderr into a ring buffer, and fans logs out to WebSocket subscribers.
|
|
11
|
+
* One instance per spawned process. Instances persist briefly after exit
|
|
12
|
+
* so late-attaching browsers can read the final log.
|
|
13
|
+
* @module dsh-better-sidebar-starter/host/processManager
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Resolve JAVA_HOME from the system when it is not already in the process
|
|
17
|
+
* environment. Checks (in order):
|
|
18
|
+
* 1. process.env.JAVA_HOME
|
|
19
|
+
* 2. Windows registry (HKLM\SOFTWARE\JavaSoft\JDK / JRE)
|
|
20
|
+
* 3. Common install paths
|
|
21
|
+
* 4. `which java` → parent dir (Unix)
|
|
22
|
+
* Returns null if not found.
|
|
23
|
+
*/
|
|
24
|
+
function resolveJavaHome() {
|
|
25
|
+
if (process.env.JAVA_HOME && process.env.JAVA_HOME.trim() !== "") {
|
|
26
|
+
const home = process.env.JAVA_HOME.trim();
|
|
27
|
+
if (existsSync(join(home, "bin", process.platform === "win32" ? "java.exe" : "java"))) return home;
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === "win32") {
|
|
30
|
+
for (const regPath of [
|
|
31
|
+
"HKLM\\SOFTWARE\\JavaSoft\\JDK",
|
|
32
|
+
"HKLM\\SOFTWARE\\JavaSoft\\Java Development Kit",
|
|
33
|
+
"HKLM\\SOFTWARE\\JavaSoft\\JRE",
|
|
34
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\JavaSoft\\JDK",
|
|
35
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\JavaSoft\\Java Development Kit",
|
|
36
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\JavaSoft\\JRE"
|
|
37
|
+
]) try {
|
|
38
|
+
const match = execFileSync("reg", [
|
|
39
|
+
"query",
|
|
40
|
+
regPath,
|
|
41
|
+
"/s",
|
|
42
|
+
"/v",
|
|
43
|
+
"JavaHome"
|
|
44
|
+
], {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
timeout: 3e3,
|
|
47
|
+
windowsHide: true
|
|
48
|
+
}).match(/JavaHome\s+REG_SZ\s+(.+)/);
|
|
49
|
+
if (match !== null) {
|
|
50
|
+
const home = match[1].trim();
|
|
51
|
+
if (existsSync(home)) return home;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
const userHome = process.env.USERPROFILE ?? "";
|
|
55
|
+
const candidates = [
|
|
56
|
+
join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Java"),
|
|
57
|
+
join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Java"),
|
|
58
|
+
join(userHome, ".jdks"),
|
|
59
|
+
join(userHome, ".jdks", "jbr-21.0.11")
|
|
60
|
+
];
|
|
61
|
+
for (const base of candidates) try {
|
|
62
|
+
if (!existsSync(base)) continue;
|
|
63
|
+
const dirs = execFileSync("cmd", [
|
|
64
|
+
"/c",
|
|
65
|
+
"dir",
|
|
66
|
+
"/b",
|
|
67
|
+
"/ad",
|
|
68
|
+
base
|
|
69
|
+
], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
timeout: 3e3,
|
|
72
|
+
windowsHide: true
|
|
73
|
+
}).split("\n").map((s) => s.trim()).filter((s) => s !== "");
|
|
74
|
+
let best = null;
|
|
75
|
+
let bestVer = -1;
|
|
76
|
+
for (const d of dirs) {
|
|
77
|
+
const home = join(base, d);
|
|
78
|
+
if (!existsSync(join(home, "bin", "java.exe"))) continue;
|
|
79
|
+
const verMatch = d.match(/(\d+)(?:\.|$)/);
|
|
80
|
+
const score = (verMatch !== null ? parseInt(verMatch[1], 10) : 0) + (/jdk|corretto|temurin/i.test(d) ? 1e3 : 0);
|
|
81
|
+
if (score > bestVer) {
|
|
82
|
+
bestVer = score;
|
|
83
|
+
best = home;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (best !== null) return best;
|
|
87
|
+
} catch {}
|
|
88
|
+
} else try {
|
|
89
|
+
const javaPath = execFileSync("which", ["java"], {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
timeout: 3e3
|
|
92
|
+
}).trim();
|
|
93
|
+
if (javaPath !== "") {
|
|
94
|
+
const realPath = execFileSync("readlink", ["-f", javaPath], {
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
timeout: 3e3
|
|
97
|
+
}).trim();
|
|
98
|
+
const home = realPath.replace(/\/bin\/java$/, "");
|
|
99
|
+
if (home !== realPath && existsSync(home)) return home;
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Resolve the Maven bin dir. Priority: config.runtime.mvn > a configured
|
|
105
|
+
* MVN_PATH > process.env.MAVEN_HOME > `where mvn` / `which mvn`. */
|
|
106
|
+
function resolveMavenBin(configRuntime, globalEnv) {
|
|
107
|
+
const rtMvn = configRuntime?.mvn?.trim();
|
|
108
|
+
if (rtMvn) {
|
|
109
|
+
if (existsSync(rtMvn)) {
|
|
110
|
+
const base = /mvn(\.cmd|\.bat)?$/i.test(rtMvn) ? rtMvn.replace(/[/\\]mvn(\.cmd|\.bat)?$/i, "") : rtMvn;
|
|
111
|
+
const binDir = /[/\\]bin$/i.test(base) ? base : join(base, "bin");
|
|
112
|
+
const exe = process.platform === "win32" ? "mvn.cmd" : "mvn";
|
|
113
|
+
if (existsSync(join(binDir, exe))) return binDir;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const gMvn = globalEnv.MVN_PATH?.trim();
|
|
117
|
+
if (gMvn) {
|
|
118
|
+
const binDir = /[/\\]bin$/i.test(gMvn) ? gMvn : join(gMvn, "bin");
|
|
119
|
+
const exe = process.platform === "win32" ? "mvn.cmd" : "mvn";
|
|
120
|
+
if (existsSync(join(binDir, exe))) return binDir;
|
|
121
|
+
}
|
|
122
|
+
const mh = process.env.MAVEN_HOME?.trim();
|
|
123
|
+
if (mh) {
|
|
124
|
+
const binDir = /[/\\]bin$/i.test(mh) ? mh : join(mh, "bin");
|
|
125
|
+
const exe = process.platform === "win32" ? "mvn.cmd" : "mvn";
|
|
126
|
+
if (existsSync(join(binDir, exe))) return binDir;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
130
|
+
const arg = process.platform === "win32" ? "mvn.cmd" : "mvn";
|
|
131
|
+
const out = execFileSync(which, [arg], {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
timeout: 3e3,
|
|
134
|
+
windowsHide: true
|
|
135
|
+
}).split("\n")[0]?.trim();
|
|
136
|
+
if (out) {
|
|
137
|
+
const lastSep = Math.max(out.lastIndexOf("/"), out.lastIndexOf("\\"));
|
|
138
|
+
return lastSep >= 0 ? out.substring(0, lastSep) : out;
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** Build the spawn env. Priority: config.runtime > globalEnv > process.env >
|
|
144
|
+
* auto-detect. Injects tool bin dirs into PATH (fixes missing mvn/node/etc). */
|
|
145
|
+
function buildEnv(config, globalEnv) {
|
|
146
|
+
const env = {
|
|
147
|
+
...process.env,
|
|
148
|
+
...globalEnv,
|
|
149
|
+
...config.env
|
|
150
|
+
};
|
|
151
|
+
const isWin = process.platform === "win32";
|
|
152
|
+
const sep = isWin ? ";" : ":";
|
|
153
|
+
const toolBins = [];
|
|
154
|
+
const javaHome = config.runtime?.java?.trim() || globalEnv.JAVA_HOME?.trim() || (process.env.JAVA_HOME?.trim() && existsSync(join(process.env.JAVA_HOME, "bin", isWin ? "java.exe" : "java")) ? process.env.JAVA_HOME.trim() : "");
|
|
155
|
+
let resolvedJava = javaHome && existsSync(join(javaHome, "bin", isWin ? "java.exe" : "java")) ? javaHome : "";
|
|
156
|
+
if (!resolvedJava) resolvedJava = resolveJavaHome() ?? "";
|
|
157
|
+
if (resolvedJava) {
|
|
158
|
+
env.JAVA_HOME = resolvedJava;
|
|
159
|
+
toolBins.push(join(resolvedJava, "bin"));
|
|
160
|
+
}
|
|
161
|
+
const nodeHome = config.runtime?.node?.trim() || globalEnv.NODE_PATH?.trim() || process.env.NODE_PATH?.trim() || "";
|
|
162
|
+
let resolvedNode = "";
|
|
163
|
+
if (nodeHome) {
|
|
164
|
+
const binDir = /[/\\]bin$/i.test(nodeHome) ? nodeHome : join(nodeHome, "bin");
|
|
165
|
+
const nodeExe = isWin ? "node.exe" : "node";
|
|
166
|
+
if (existsSync(join(binDir, nodeExe))) resolvedNode = binDir;
|
|
167
|
+
else if (existsSync(join(nodeHome, nodeExe))) resolvedNode = nodeHome;
|
|
168
|
+
}
|
|
169
|
+
if (resolvedNode) {
|
|
170
|
+
env.NODE_PATH = resolvedNode.replace(/[/\\]bin$/, "");
|
|
171
|
+
toolBins.push(resolvedNode);
|
|
172
|
+
}
|
|
173
|
+
const pythonHome = config.runtime?.python?.trim() || globalEnv.PYTHON_PATH?.trim() || process.env.PYTHON_PATH?.trim() || "";
|
|
174
|
+
let resolvedPython = "";
|
|
175
|
+
if (pythonHome) {
|
|
176
|
+
const binDir = /[/\\]bin$/i.test(pythonHome) ? pythonHome : join(pythonHome, "bin");
|
|
177
|
+
const pyNames = isWin ? ["python.exe"] : ["python", "python3"];
|
|
178
|
+
const hasPy = pyNames.some((n) => existsSync(join(binDir, n)));
|
|
179
|
+
const hasPyRoot = pyNames.some((n) => existsSync(join(pythonHome, n)));
|
|
180
|
+
if (hasPy) resolvedPython = binDir;
|
|
181
|
+
else if (hasPyRoot) resolvedPython = pythonHome;
|
|
182
|
+
}
|
|
183
|
+
if (resolvedPython) {
|
|
184
|
+
env.PYTHON_PATH = resolvedPython.replace(/[/\\]bin$/, "");
|
|
185
|
+
toolBins.push(resolvedPython);
|
|
186
|
+
}
|
|
187
|
+
const mvnBin = resolveMavenBin(config.runtime, globalEnv);
|
|
188
|
+
if (mvnBin) {
|
|
189
|
+
env.MAVEN_HOME = mvnBin.replace(/[/\\]bin$/, "");
|
|
190
|
+
toolBins.push(mvnBin);
|
|
191
|
+
}
|
|
192
|
+
if (isWin) {
|
|
193
|
+
const windir = process.env.WINDIR ?? process.env.SystemRoot ?? "C:\\Windows";
|
|
194
|
+
const sys32 = process.env.SystemRoot ? join(process.env.SystemRoot, "System32") : "C:\\Windows\\System32";
|
|
195
|
+
const sysNative = process.env.SystemRoot ? join(process.env.SystemRoot, "SysWOW64") : "C:\\Windows\\SysWOW64";
|
|
196
|
+
for (const d of [
|
|
197
|
+
windir,
|
|
198
|
+
sys32,
|
|
199
|
+
sysNative
|
|
200
|
+
]) if (d && !toolBins.includes(d)) toolBins.push(d);
|
|
201
|
+
}
|
|
202
|
+
if (toolBins.length > 0) {
|
|
203
|
+
const existing = env.PATH ?? "";
|
|
204
|
+
env.PATH = [...toolBins, existing].filter(Boolean).join(sep);
|
|
205
|
+
}
|
|
206
|
+
return env;
|
|
207
|
+
}
|
|
208
|
+
/** Ring buffer cap (1 MB of text; each entry's text counted as UTF-16 length). */
|
|
209
|
+
const RING_BUFFER_MAX_CHARS = 1e6;
|
|
210
|
+
/** How long an exited instance stays available for late log reads. */
|
|
211
|
+
const EXIT_RETENTION_MS = 3e4;
|
|
212
|
+
/**
|
|
213
|
+
* Process manager owns the instance table and the WebSocket fan-out.
|
|
214
|
+
* Call `spawn(config, cwd)` to start, `stop(instanceId)` to kill, and
|
|
215
|
+
* `attach(instanceId, ws)` to subscribe a browser to the log stream.
|
|
216
|
+
*/
|
|
217
|
+
var ProcessManager = class {
|
|
218
|
+
processes = /* @__PURE__ */ new Map();
|
|
219
|
+
/** Spawn a config as a child process. Returns the new RunInstance. */
|
|
220
|
+
spawnConfig(config, resolvedCwd, globalEnv = {}) {
|
|
221
|
+
const id = randomUUID();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
let finalCommand = config.command;
|
|
224
|
+
if (config.type === "springboot") {
|
|
225
|
+
const parts = [config.command];
|
|
226
|
+
const jvm = config.jvmArgs?.trim() ?? "";
|
|
227
|
+
const appArgs = config.args?.trim() ?? "";
|
|
228
|
+
if (/spring-boot\s*:/i.test(config.command) || /^mvn\w*\s+spring-boot/i.test(config.command)) {
|
|
229
|
+
if (jvm !== "") parts.push(`-Dspring-boot.run.jvmArguments="${jvm}"`);
|
|
230
|
+
if (appArgs !== "") parts.push(`-Dspring-boot.run.arguments="${appArgs}"`);
|
|
231
|
+
} else {
|
|
232
|
+
if (jvm !== "") parts.push(jvm);
|
|
233
|
+
if (appArgs !== "") parts.push(appArgs);
|
|
234
|
+
}
|
|
235
|
+
finalCommand = parts.join(" ");
|
|
236
|
+
} else if (config.args && config.args.trim()) finalCommand = `${config.command} ${config.args.trim()}`;
|
|
237
|
+
const instance = {
|
|
238
|
+
id,
|
|
239
|
+
configId: config.id,
|
|
240
|
+
configName: config.name,
|
|
241
|
+
configType: config.type,
|
|
242
|
+
command: finalCommand,
|
|
243
|
+
cwd: resolvedCwd,
|
|
244
|
+
status: "running",
|
|
245
|
+
pid: 0,
|
|
246
|
+
startedAt: now,
|
|
247
|
+
exitedAt: null,
|
|
248
|
+
exitCode: null
|
|
249
|
+
};
|
|
250
|
+
const managed = {
|
|
251
|
+
instance,
|
|
252
|
+
child: null,
|
|
253
|
+
logs: [],
|
|
254
|
+
logChars: 0,
|
|
255
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
256
|
+
exitTimer: null
|
|
257
|
+
};
|
|
258
|
+
let child;
|
|
259
|
+
try {
|
|
260
|
+
const cmd = process.platform === "win32" && !/^chcp\s/i.test(finalCommand) ? `chcp 65001 >nul && ${finalCommand}` : finalCommand;
|
|
261
|
+
child = spawn(cmd, {
|
|
262
|
+
cwd: resolvedCwd,
|
|
263
|
+
env: buildEnv(config, globalEnv),
|
|
264
|
+
shell: true,
|
|
265
|
+
detached: process.platform !== "win32",
|
|
266
|
+
windowsHide: false
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
instance.status = "error";
|
|
270
|
+
instance.exitCode = -1;
|
|
271
|
+
instance.exitedAt = Date.now();
|
|
272
|
+
this.processes.set(id, managed);
|
|
273
|
+
this.scheduleCleanup(managed);
|
|
274
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
275
|
+
this.appendLog(managed, "stderr", `Failed to spawn: ${msg}\n`);
|
|
276
|
+
return instance;
|
|
277
|
+
}
|
|
278
|
+
managed.child = child;
|
|
279
|
+
instance.pid = child.pid ?? 0;
|
|
280
|
+
this.processes.set(id, managed);
|
|
281
|
+
child.stdout?.on("data", (chunk) => {
|
|
282
|
+
this.appendLog(managed, "stdout", chunk.toString("utf8"));
|
|
283
|
+
});
|
|
284
|
+
child.stderr?.on("data", (chunk) => {
|
|
285
|
+
this.appendLog(managed, "stderr", chunk.toString("utf8"));
|
|
286
|
+
});
|
|
287
|
+
child.on("error", (error) => {
|
|
288
|
+
this.appendLog(managed, "stderr", `Process error: ${error.message}\n`);
|
|
289
|
+
this.setExited(managed, "error", -1);
|
|
290
|
+
});
|
|
291
|
+
child.on("close", (code) => {
|
|
292
|
+
this.setExited(managed, "exited", code ?? 0);
|
|
293
|
+
});
|
|
294
|
+
return instance;
|
|
295
|
+
}
|
|
296
|
+
/** Stop a running instance (kills the process tree). No-op if not running. */
|
|
297
|
+
stop(instanceId) {
|
|
298
|
+
const managed = this.processes.get(instanceId);
|
|
299
|
+
if (managed === void 0) return false;
|
|
300
|
+
if (managed.instance.status !== "running") return false;
|
|
301
|
+
if (managed.child === null) return false;
|
|
302
|
+
const pid = managed.child.pid;
|
|
303
|
+
if (pid === void 0) return false;
|
|
304
|
+
try {
|
|
305
|
+
if (process.platform === "win32") execFileSync("taskkill", [
|
|
306
|
+
"/T",
|
|
307
|
+
"/F",
|
|
308
|
+
"/PID",
|
|
309
|
+
String(pid)
|
|
310
|
+
], {
|
|
311
|
+
windowsHide: true,
|
|
312
|
+
stdio: "ignore"
|
|
313
|
+
});
|
|
314
|
+
else try {
|
|
315
|
+
process.kill(-pid, "SIGTERM");
|
|
316
|
+
} catch {
|
|
317
|
+
process.kill(pid, "SIGTERM");
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
try {
|
|
321
|
+
managed.child.kill("SIGTERM");
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
this.setExited(managed, "killed", null);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
/** List all instances (running + recently exited). */
|
|
328
|
+
listInstances() {
|
|
329
|
+
return Array.from(this.processes.values()).map((m) => m.instance);
|
|
330
|
+
}
|
|
331
|
+
/** Get one instance by id. */
|
|
332
|
+
getInstance(instanceId) {
|
|
333
|
+
return this.processes.get(instanceId)?.instance;
|
|
334
|
+
}
|
|
335
|
+
/** Get buffered logs for an instance. */
|
|
336
|
+
getLogs(instanceId) {
|
|
337
|
+
return this.processes.get(instanceId)?.logs ?? [];
|
|
338
|
+
}
|
|
339
|
+
/** Subscribe a WebSocket to an instance's log stream. */
|
|
340
|
+
attach(instanceId, ws) {
|
|
341
|
+
const managed = this.processes.get(instanceId);
|
|
342
|
+
if (managed === void 0) return false;
|
|
343
|
+
managed.subscribers.add(ws);
|
|
344
|
+
ws.on("close", () => {
|
|
345
|
+
managed.subscribers.delete(ws);
|
|
346
|
+
});
|
|
347
|
+
ws.on("error", () => {
|
|
348
|
+
managed.subscribers.delete(ws);
|
|
349
|
+
});
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
/** Append a log entry to the ring buffer and fan out to subscribers. */
|
|
353
|
+
appendLog(managed, stream, text) {
|
|
354
|
+
const entry = {
|
|
355
|
+
stream,
|
|
356
|
+
text,
|
|
357
|
+
ts: Date.now()
|
|
358
|
+
};
|
|
359
|
+
managed.logs.push(entry);
|
|
360
|
+
managed.logChars += text.length;
|
|
361
|
+
while (managed.logChars > RING_BUFFER_MAX_CHARS && managed.logs.length > 1) {
|
|
362
|
+
const dropped = managed.logs.shift();
|
|
363
|
+
if (dropped !== void 0) managed.logChars -= dropped.text.length;
|
|
364
|
+
}
|
|
365
|
+
const msg = JSON.stringify({
|
|
366
|
+
type: "log",
|
|
367
|
+
entry
|
|
368
|
+
});
|
|
369
|
+
for (const ws of managed.subscribers) try {
|
|
370
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
|
371
|
+
} catch {}
|
|
372
|
+
}
|
|
373
|
+
/** Mark an instance as exited and notify subscribers. */
|
|
374
|
+
setExited(managed, status, exitCode) {
|
|
375
|
+
if (managed.instance.status !== "running") return;
|
|
376
|
+
managed.instance.status = status;
|
|
377
|
+
managed.instance.exitedAt = Date.now();
|
|
378
|
+
managed.instance.exitCode = exitCode;
|
|
379
|
+
const msg = JSON.stringify({
|
|
380
|
+
type: "status",
|
|
381
|
+
status,
|
|
382
|
+
exitCode
|
|
383
|
+
});
|
|
384
|
+
for (const ws of managed.subscribers) try {
|
|
385
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
|
386
|
+
} catch {}
|
|
387
|
+
this.scheduleCleanup(managed);
|
|
388
|
+
}
|
|
389
|
+
/** Schedule instance removal after the retention window. */
|
|
390
|
+
scheduleCleanup(managed) {
|
|
391
|
+
if (managed.exitTimer !== null) return;
|
|
392
|
+
managed.exitTimer = setTimeout(() => {
|
|
393
|
+
this.processes.delete(managed.instance.id);
|
|
394
|
+
}, EXIT_RETENTION_MS);
|
|
395
|
+
}
|
|
396
|
+
/** Stop all running processes and dispose (plugin teardown). */
|
|
397
|
+
dispose() {
|
|
398
|
+
for (const managed of this.processes.values()) {
|
|
399
|
+
if (managed.exitTimer !== null) clearTimeout(managed.exitTimer);
|
|
400
|
+
if (managed.instance.status === "running" && managed.child !== null) try {
|
|
401
|
+
const pid = managed.child.pid;
|
|
402
|
+
if (pid !== void 0) {
|
|
403
|
+
if (process.platform === "win32") execFileSync("taskkill", [
|
|
404
|
+
"/T",
|
|
405
|
+
"/F",
|
|
406
|
+
"/PID",
|
|
407
|
+
String(pid)
|
|
408
|
+
], {
|
|
409
|
+
windowsHide: true,
|
|
410
|
+
stdio: "ignore"
|
|
411
|
+
});
|
|
412
|
+
else try {
|
|
413
|
+
process.kill(-pid, "SIGTERM");
|
|
414
|
+
} catch {
|
|
415
|
+
process.kill(pid, "SIGTERM");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch {}
|
|
419
|
+
}
|
|
420
|
+
this.processes.clear();
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/host/fence.ts
|
|
425
|
+
function header(headers, name) {
|
|
426
|
+
const value = headers[name];
|
|
427
|
+
return typeof value === "string" ? value : void 0;
|
|
428
|
+
}
|
|
429
|
+
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
430
|
+
function parseAuthority(authority) {
|
|
431
|
+
try {
|
|
432
|
+
return new URL(`http://${authority}`);
|
|
433
|
+
} catch {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
438
|
+
function isLoopbackHostname(hostname) {
|
|
439
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
440
|
+
const parts = hostname.split(".");
|
|
441
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
442
|
+
}
|
|
443
|
+
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
444
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
445
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
446
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
447
|
+
}
|
|
448
|
+
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
|
449
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
450
|
+
return trustedHosts.some((entry) => {
|
|
451
|
+
const entryUrl = parseAuthority(entry);
|
|
452
|
+
if (entryUrl === void 0) return false;
|
|
453
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Decide whether one request may reach the plugin routes.
|
|
458
|
+
* @param request - node HTTP request facts (headers).
|
|
459
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
460
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
461
|
+
*/
|
|
462
|
+
function isTrustedApiRequest(request, trustedHosts) {
|
|
463
|
+
const host = header(request.headers, "host");
|
|
464
|
+
if (host === void 0) return false;
|
|
465
|
+
const hostUrl = parseAuthority(host);
|
|
466
|
+
if (hostUrl === void 0) return false;
|
|
467
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
468
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
469
|
+
const origin = header(request.headers, "origin");
|
|
470
|
+
if (origin === void 0) return true;
|
|
471
|
+
try {
|
|
472
|
+
return new URL(origin).host === hostUrl.host;
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/host/gate.ts
|
|
479
|
+
/**
|
|
480
|
+
* Session-scoped path gate for the starter plugin routes. Every config/instance
|
|
481
|
+
* request carries a sessionId (+ optional client cwd) and a path; this gate
|
|
482
|
+
* resolves the session's AUTHORITATIVE working directory from the session
|
|
483
|
+
* store (the same source dsh-better-sidebar uses) and requires the
|
|
484
|
+
* (canonicalized) target to live inside it — a path outside the
|
|
485
|
+
* conversation's cwd is refused, exactly like the sidebar's own fs routes.
|
|
486
|
+
* @module dsh-better-sidebar-starter/host/gate
|
|
487
|
+
*/
|
|
488
|
+
/** Case-insensitive (on win32) containment check with both separators normalized. */
|
|
489
|
+
function isPathInside(root, child) {
|
|
490
|
+
if (root === "" || child === "") return false;
|
|
491
|
+
const norm = (value) => value.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
492
|
+
const normRoot = norm(root);
|
|
493
|
+
const normChild = norm(child);
|
|
494
|
+
if (process.platform === "win32") {
|
|
495
|
+
const a = normRoot.toLowerCase();
|
|
496
|
+
const b = normChild.toLowerCase();
|
|
497
|
+
if (b === a) return true;
|
|
498
|
+
return b.startsWith(`${a}/`);
|
|
499
|
+
}
|
|
500
|
+
if (normChild === normRoot) return true;
|
|
501
|
+
return normChild.startsWith(`${normRoot}/`);
|
|
502
|
+
}
|
|
503
|
+
/** The session's authoritative working directory (header cwd wins, then the
|
|
504
|
+
* client-provided cwd, then the process cwd — never throws for a blank one). */
|
|
505
|
+
function sessionCwdOf(ctx, sessionId, clientCwd) {
|
|
506
|
+
const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
|
|
507
|
+
if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
|
|
508
|
+
if (clientCwd !== void 0 && clientCwd !== "") return clientCwd;
|
|
509
|
+
return process.cwd();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Build a gate bound to one session scope. The requested path may be
|
|
513
|
+
* absolute (the common case — the sidebar passes absolute paths) or
|
|
514
|
+
* relative to the session cwd; the canonicalized result is required to be
|
|
515
|
+
* inside the (canonicalized) session cwd.
|
|
516
|
+
*/
|
|
517
|
+
function createSessionGate(ctx, sessionId, clientCwd) {
|
|
518
|
+
return async (raw) => {
|
|
519
|
+
if (typeof raw !== "string" || raw === "") return {
|
|
520
|
+
ok: false,
|
|
521
|
+
error: "empty path"
|
|
522
|
+
};
|
|
523
|
+
const cwd = sessionCwdOf(ctx, sessionId, clientCwd);
|
|
524
|
+
let target = isAbsolute(raw) ? raw : join(cwd, raw);
|
|
525
|
+
let canonicalCwd;
|
|
526
|
+
try {
|
|
527
|
+
target = await realpath(target);
|
|
528
|
+
canonicalCwd = await realpath(cwd);
|
|
529
|
+
} catch {
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
error: "path does not resolve on disk"
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
if (!isPathInside(canonicalCwd, target)) return {
|
|
536
|
+
ok: false,
|
|
537
|
+
error: "path is outside the session working directory"
|
|
538
|
+
};
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
canonical: target
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/host/configStore.ts
|
|
547
|
+
/**
|
|
548
|
+
* Config store: CRUD for run configurations, persisted as JSON in the
|
|
549
|
+
* session's working directory (`<cwd>/.dsh/run-configs.json`). All paths
|
|
550
|
+
* are canonical (already passed through the session gate) — this service
|
|
551
|
+
* trusts its caller for path safety.
|
|
552
|
+
* @module dsh-better-sidebar-starter/host/configStore
|
|
553
|
+
*/
|
|
554
|
+
const FILE_VERSION = 1;
|
|
555
|
+
const CONFIG_DIR = ".dsh";
|
|
556
|
+
const CONFIG_FILE = "run-configs.json";
|
|
557
|
+
/** Resolve the config file path inside a canonical session cwd. */
|
|
558
|
+
function configFilePath(cwd) {
|
|
559
|
+
return join(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
560
|
+
}
|
|
561
|
+
/** Read all configs from the session's working directory. */
|
|
562
|
+
async function readConfigs(cwd) {
|
|
563
|
+
const filePath = configFilePath(cwd);
|
|
564
|
+
let raw;
|
|
565
|
+
try {
|
|
566
|
+
raw = await readFile(filePath, "utf8");
|
|
567
|
+
} catch {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(raw);
|
|
572
|
+
if (!Array.isArray(parsed.configs)) return [];
|
|
573
|
+
return parsed.configs.filter((c) => typeof c === "object" && c !== null && typeof c.id === "string" && typeof c.name === "string" && typeof c.type === "string" && typeof c.command === "string" && typeof c.cwd === "string" && typeof c.env === "object" && c.env !== null && (typeof c.jvmArgs === "string" || c.jvmArgs === void 0) && (typeof c.args === "string" || c.args === void 0) && (typeof c.runtime === "object" || c.runtime === void 0) && typeof c.createdAt === "number" && typeof c.updatedAt === "number" && (c.lastRunAt === null || typeof c.lastRunAt === "number"));
|
|
574
|
+
} catch {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/** Write all configs to the session's working directory (atomic-ish: mkdir then write). */
|
|
579
|
+
async function writeConfigs(cwd, configs) {
|
|
580
|
+
const filePath = configFilePath(cwd);
|
|
581
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
582
|
+
await writeFile(filePath, JSON.stringify({
|
|
583
|
+
configs,
|
|
584
|
+
version: FILE_VERSION
|
|
585
|
+
}, null, 2), "utf8");
|
|
586
|
+
}
|
|
587
|
+
/** Create a new config or update an existing one (matched by id). */
|
|
588
|
+
async function upsertConfig(cwd, input) {
|
|
589
|
+
const configs = await readConfigs(cwd);
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
if (input.id !== void 0) {
|
|
592
|
+
const idx = configs.findIndex((c) => c.id === input.id);
|
|
593
|
+
if (idx >= 0) {
|
|
594
|
+
const existing = configs[idx];
|
|
595
|
+
const updated = {
|
|
596
|
+
...existing,
|
|
597
|
+
name: input.name,
|
|
598
|
+
type: input.type ?? existing.type,
|
|
599
|
+
command: input.command ?? existing.command,
|
|
600
|
+
cwd: input.cwd ?? existing.cwd,
|
|
601
|
+
env: input.env ?? existing.env,
|
|
602
|
+
jvmArgs: input.jvmArgs ?? existing.jvmArgs,
|
|
603
|
+
args: input.args ?? existing.args,
|
|
604
|
+
runtime: input.runtime ?? existing.runtime,
|
|
605
|
+
updatedAt: now
|
|
606
|
+
};
|
|
607
|
+
configs[idx] = updated;
|
|
608
|
+
await writeConfigs(cwd, configs);
|
|
609
|
+
return updated;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const config = {
|
|
613
|
+
id: input.id ?? randomUUID(),
|
|
614
|
+
name: input.name,
|
|
615
|
+
type: input.type ?? "custom",
|
|
616
|
+
command: input.command ?? "",
|
|
617
|
+
cwd: input.cwd ?? ".",
|
|
618
|
+
env: input.env ?? {},
|
|
619
|
+
jvmArgs: input.jvmArgs ?? "",
|
|
620
|
+
args: input.args ?? "",
|
|
621
|
+
runtime: input.runtime,
|
|
622
|
+
createdAt: now,
|
|
623
|
+
updatedAt: now,
|
|
624
|
+
lastRunAt: null
|
|
625
|
+
};
|
|
626
|
+
configs.push(config);
|
|
627
|
+
await writeConfigs(cwd, configs);
|
|
628
|
+
return config;
|
|
629
|
+
}
|
|
630
|
+
/** Delete a config by id. Returns true if the config existed and was removed. */
|
|
631
|
+
async function deleteConfig(cwd, id) {
|
|
632
|
+
const configs = await readConfigs(cwd);
|
|
633
|
+
const idx = configs.findIndex((c) => c.id === id);
|
|
634
|
+
if (idx < 0) return false;
|
|
635
|
+
configs.splice(idx, 1);
|
|
636
|
+
await writeConfigs(cwd, configs);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
/** Stamp a config's lastRunAt (called when an instance starts). */
|
|
640
|
+
async function stampLastRun(cwd, id) {
|
|
641
|
+
const configs = await readConfigs(cwd);
|
|
642
|
+
const idx = configs.findIndex((c) => c.id === id);
|
|
643
|
+
if (idx < 0) return;
|
|
644
|
+
configs[idx].lastRunAt = Date.now();
|
|
645
|
+
await writeConfigs(cwd, configs);
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/host/routes.ts
|
|
649
|
+
/** Route family base. */
|
|
650
|
+
const STARTER_API = {
|
|
651
|
+
configs: "/api/dsh-better-sidebar-starter/configs",
|
|
652
|
+
instances: "/api/dsh-better-sidebar-starter/instances",
|
|
653
|
+
run: "/api/dsh-better-sidebar-starter/run",
|
|
654
|
+
stop: "/api/dsh-better-sidebar-starter/stop",
|
|
655
|
+
logsWs: "/api/dsh-better-sidebar-starter/logs",
|
|
656
|
+
detectEnv: "/api/dsh-better-sidebar-starter/detect-env"
|
|
657
|
+
};
|
|
658
|
+
/** Cap on JSON request bodies. */
|
|
659
|
+
const MAX_JSON_BODY_BYTES = 1048576;
|
|
660
|
+
/** One noServer WebSocket server for log streams. */
|
|
661
|
+
const logsWss = new WebSocketServer({ noServer: true });
|
|
662
|
+
function writeJson(res, status, body) {
|
|
663
|
+
const payload = JSON.stringify(body);
|
|
664
|
+
res.writeHead(status, {
|
|
665
|
+
"content-type": "application/json; charset=utf-8",
|
|
666
|
+
"referrer-policy": "no-referrer"
|
|
667
|
+
});
|
|
668
|
+
res.end(payload);
|
|
669
|
+
}
|
|
670
|
+
async function readJsonBody(req) {
|
|
671
|
+
const chunks = [];
|
|
672
|
+
let size = 0;
|
|
673
|
+
for await (const chunk of req) {
|
|
674
|
+
const buffer = chunk;
|
|
675
|
+
size += buffer.length;
|
|
676
|
+
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
677
|
+
chunks.push(buffer);
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
681
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
682
|
+
} catch {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function queryParam(url, name) {
|
|
687
|
+
const value = url.searchParams.get(name);
|
|
688
|
+
return value === null ? void 0 : value;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Build every starter route plus the logs WebSocket upgrade.
|
|
692
|
+
* @param deps - ctx, kernels (process manager), fence.
|
|
693
|
+
* @returns routes and the upgrade route.
|
|
694
|
+
*/
|
|
695
|
+
function makeRoutes(deps) {
|
|
696
|
+
const { ctx, kernels, fence } = deps;
|
|
697
|
+
const guard = (req, res, method) => {
|
|
698
|
+
if (!fence(req)) {
|
|
699
|
+
writeJson(res, 403, { error: "forbidden" });
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (req.method !== method) {
|
|
703
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method ?? ""}` });
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
return true;
|
|
707
|
+
};
|
|
708
|
+
/** Resolve the session cwd (canonicalized through the gate). */
|
|
709
|
+
const resolveCwd = async (url, res) => {
|
|
710
|
+
const sessionId = queryParam(url, "sessionId");
|
|
711
|
+
if (sessionId === void 0 || sessionId === "") {
|
|
712
|
+
writeJson(res, 400, { error: "sessionId is required" });
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
const cwd = sessionCwdOf(ctx, sessionId, queryParam(url, "cwd"));
|
|
716
|
+
const verdict = await createSessionGate(ctx, sessionId, queryParam(url, "cwd"))(cwd);
|
|
717
|
+
if (!verdict.ok) {
|
|
718
|
+
writeJson(res, 400, { error: `workspace gate: ${verdict.error}` });
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return verdict.canonical;
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
routes: [
|
|
725
|
+
{
|
|
726
|
+
kind: "exact",
|
|
727
|
+
path: STARTER_API.configs,
|
|
728
|
+
handler: async (req, res) => {
|
|
729
|
+
if (!fence(req)) {
|
|
730
|
+
writeJson(res, 403, { error: "forbidden" });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
734
|
+
const method = req.method ?? "GET";
|
|
735
|
+
if (method === "GET") {
|
|
736
|
+
const cwd = await resolveCwd(url, res);
|
|
737
|
+
if (cwd === null) return;
|
|
738
|
+
writeJson(res, 200, { configs: await readConfigs(cwd) });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (method === "POST") {
|
|
742
|
+
const cwd = await resolveCwd(url, res);
|
|
743
|
+
if (cwd === null) return;
|
|
744
|
+
const body = await readJsonBody(req);
|
|
745
|
+
if (body === void 0 || body.config === void 0 || typeof body.config !== "object") {
|
|
746
|
+
writeJson(res, 400, { error: "invalid JSON body: { config } required" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const input = body.config;
|
|
750
|
+
if (typeof input.name !== "string" || input.name.trim() === "") {
|
|
751
|
+
writeJson(res, 400, { error: "config.name is required" });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
writeJson(res, 200, { config: await upsertConfig(cwd, {
|
|
755
|
+
id: typeof input.id === "string" ? input.id : void 0,
|
|
756
|
+
name: input.name,
|
|
757
|
+
type: typeof input.type === "string" ? input.type : "custom",
|
|
758
|
+
command: typeof input.command === "string" ? input.command : "",
|
|
759
|
+
cwd: typeof input.cwd === "string" ? input.cwd : ".",
|
|
760
|
+
env: typeof input.env === "object" && input.env !== null ? input.env : {},
|
|
761
|
+
jvmArgs: typeof input.jvmArgs === "string" ? input.jvmArgs : "",
|
|
762
|
+
args: typeof input.args === "string" ? input.args : "",
|
|
763
|
+
runtime: typeof input.runtime === "object" && input.runtime !== null ? input.runtime : void 0
|
|
764
|
+
}) });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (method === "DELETE") {
|
|
768
|
+
const cwd = await resolveCwd(url, res);
|
|
769
|
+
if (cwd === null) return;
|
|
770
|
+
const id = queryParam(url, "id");
|
|
771
|
+
if (id === void 0) {
|
|
772
|
+
writeJson(res, 400, { error: "id is required" });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const removed = await deleteConfig(cwd, id);
|
|
776
|
+
for (const inst of kernels.listInstances()) if (inst.configId === id && inst.status === "running") kernels.stop(inst.id);
|
|
777
|
+
writeJson(res, 200, {
|
|
778
|
+
ok: true,
|
|
779
|
+
removed
|
|
780
|
+
});
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
kind: "exact",
|
|
788
|
+
path: STARTER_API.instances,
|
|
789
|
+
handler: async (req, res) => {
|
|
790
|
+
if (!guard(req, res, "GET")) return;
|
|
791
|
+
const sessionId = queryParam(new URL(req.url ?? "/", "http://localhost"), "sessionId");
|
|
792
|
+
if (sessionId === void 0 || sessionId === "") {
|
|
793
|
+
writeJson(res, 400, { error: "sessionId is required" });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
writeJson(res, 200, { instances: kernels.listInstances() });
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
kind: "exact",
|
|
801
|
+
path: STARTER_API.run,
|
|
802
|
+
handler: async (req, res) => {
|
|
803
|
+
if (!guard(req, res, "POST")) return;
|
|
804
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
805
|
+
const cwd = await resolveCwd(url, res);
|
|
806
|
+
if (cwd === null) return;
|
|
807
|
+
const body = await readJsonBody(req);
|
|
808
|
+
if (body === void 0 || typeof body.configId !== "string") {
|
|
809
|
+
writeJson(res, 400, { error: "invalid JSON body: { configId } required" });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const config = (await readConfigs(cwd)).find((c) => c.id === body.configId);
|
|
813
|
+
if (config === void 0) {
|
|
814
|
+
writeJson(res, 404, { error: "config not found" });
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const { isAbsolute, resolve } = await import("node:path");
|
|
818
|
+
const configCwd = config.cwd === "." || config.cwd === "" ? cwd : isAbsolute(config.cwd) ? config.cwd : resolve(cwd, config.cwd);
|
|
819
|
+
const verdict = await createSessionGate(ctx, queryParam(url, "sessionId") ?? "", queryParam(url, "cwd"))(configCwd);
|
|
820
|
+
if (!verdict.ok) {
|
|
821
|
+
writeJson(res, 400, { error: `config cwd gate: ${verdict.error}` });
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
await stampLastRun(cwd, config.id);
|
|
825
|
+
const globalEnv = typeof body.globalEnv === "object" && body.globalEnv !== null ? body.globalEnv : {};
|
|
826
|
+
writeJson(res, 200, { instance: kernels.spawnConfig(config, verdict.canonical, globalEnv) });
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
kind: "exact",
|
|
831
|
+
path: STARTER_API.stop,
|
|
832
|
+
handler: async (req, res) => {
|
|
833
|
+
if (!guard(req, res, "POST")) return;
|
|
834
|
+
const body = await readJsonBody(req);
|
|
835
|
+
if (body === void 0 || typeof body.instanceId !== "string") {
|
|
836
|
+
writeJson(res, 400, { error: "invalid JSON body: { instanceId } required" });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
writeJson(res, 200, {
|
|
840
|
+
ok: true,
|
|
841
|
+
stopped: kernels.stop(body.instanceId)
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
kind: "exact",
|
|
847
|
+
path: STARTER_API.detectEnv,
|
|
848
|
+
handler: async (req, res) => {
|
|
849
|
+
if (!fence(req)) {
|
|
850
|
+
writeJson(res, 403, { error: "forbidden" });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const { existsSync } = await import("node:fs");
|
|
854
|
+
const { join } = await import("node:path");
|
|
855
|
+
const { execFileSync } = await import("node:child_process");
|
|
856
|
+
const isWin = process.platform === "win32";
|
|
857
|
+
const result = {};
|
|
858
|
+
result.home = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
|
859
|
+
result._diagSystemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? "(none)";
|
|
860
|
+
result._diagPathHasSystem32 = (process.env.PATH ?? "").toLowerCase().includes("system32");
|
|
861
|
+
if (process.env.JAVA_HOME && process.env.JAVA_HOME.trim() !== "") {
|
|
862
|
+
const home = process.env.JAVA_HOME.trim();
|
|
863
|
+
if (existsSync(join(home, "bin", isWin ? "java.exe" : "java"))) result.javaHome = home;
|
|
864
|
+
}
|
|
865
|
+
if (result.javaHome === void 0) {
|
|
866
|
+
const userHome = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
|
867
|
+
const scanRoots = isWin ? [join(userHome, ".jdks")] : [
|
|
868
|
+
"/Library/Java/JavaVirtualMachines",
|
|
869
|
+
join(userHome, "Library/Java/JavaVirtualMachines"),
|
|
870
|
+
"/opt/homebrew/opt",
|
|
871
|
+
"/usr/lib/jvm",
|
|
872
|
+
"/usr/java"
|
|
873
|
+
];
|
|
874
|
+
const listDirs = (base) => {
|
|
875
|
+
if (!existsSync(base)) return [];
|
|
876
|
+
if (isWin) return execFileSync("cmd", [
|
|
877
|
+
"/c",
|
|
878
|
+
"dir",
|
|
879
|
+
"/b",
|
|
880
|
+
"/ad",
|
|
881
|
+
base
|
|
882
|
+
], {
|
|
883
|
+
encoding: "utf8",
|
|
884
|
+
timeout: 3e3,
|
|
885
|
+
windowsHide: true
|
|
886
|
+
}).split("\n").map((s) => s.trim()).filter((s) => s !== "");
|
|
887
|
+
return execFileSync("ls", [
|
|
888
|
+
"-1",
|
|
889
|
+
"-p",
|
|
890
|
+
base
|
|
891
|
+
], {
|
|
892
|
+
encoding: "utf8",
|
|
893
|
+
timeout: 3e3
|
|
894
|
+
}).split("\n").map((s) => s.trim().replace(/\/$/, "")).filter((s) => s !== "");
|
|
895
|
+
};
|
|
896
|
+
for (const base of scanRoots) try {
|
|
897
|
+
const dirs = listDirs(base);
|
|
898
|
+
let best = null;
|
|
899
|
+
let bestScore = -1;
|
|
900
|
+
for (const d of dirs) {
|
|
901
|
+
const home = join(base, d);
|
|
902
|
+
if (existsSync(join(home, "bin", isWin ? "java.exe" : "java"))) {
|
|
903
|
+
const verMatch = d.match(/(\d+)(?:\.|$)/);
|
|
904
|
+
const score = (verMatch ? parseInt(verMatch[1], 10) : 0) + (/jdk|corretto|temurin|zulu|liberica/i.test(d) ? 1e3 : 0);
|
|
905
|
+
if (score > bestScore) {
|
|
906
|
+
bestScore = score;
|
|
907
|
+
best = home;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (best !== null) {
|
|
912
|
+
result.javaHome = best;
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
} catch {}
|
|
916
|
+
}
|
|
917
|
+
if (process.env.NODE_PATH && process.env.NODE_PATH.trim() !== "") result.nodePath = process.env.NODE_PATH.trim();
|
|
918
|
+
else try {
|
|
919
|
+
const nodeBin = execFileSync(isWin ? "where" : "which", isWin ? ["node.exe"] : ["node"], {
|
|
920
|
+
encoding: "utf8",
|
|
921
|
+
timeout: 3e3,
|
|
922
|
+
windowsHide: true
|
|
923
|
+
}).split("\n")[0]?.trim();
|
|
924
|
+
if (nodeBin) {
|
|
925
|
+
const lastSep = Math.max(nodeBin.lastIndexOf("/"), nodeBin.lastIndexOf("\\"));
|
|
926
|
+
result.nodePath = lastSep >= 0 ? nodeBin.substring(0, lastSep) : nodeBin;
|
|
927
|
+
}
|
|
928
|
+
} catch {}
|
|
929
|
+
if (process.env.PYTHON_PATH && process.env.PYTHON_PATH.trim() !== "") result.pythonPath = process.env.PYTHON_PATH.trim();
|
|
930
|
+
else try {
|
|
931
|
+
const pyBin = execFileSync(isWin ? "where" : "which", isWin ? ["python.exe"] : ["python3"], {
|
|
932
|
+
encoding: "utf8",
|
|
933
|
+
timeout: 3e3,
|
|
934
|
+
windowsHide: true
|
|
935
|
+
}).split("\n")[0]?.trim();
|
|
936
|
+
if (pyBin) {
|
|
937
|
+
const lastSep = Math.max(pyBin.lastIndexOf("/"), pyBin.lastIndexOf("\\"));
|
|
938
|
+
result.pythonPath = lastSep >= 0 ? pyBin.substring(0, lastSep) : pyBin;
|
|
939
|
+
}
|
|
940
|
+
} catch {}
|
|
941
|
+
if (process.env.MAVEN_HOME && process.env.MAVEN_HOME.trim() !== "") result.mvnPath = process.env.MAVEN_HOME.trim();
|
|
942
|
+
else if (process.env.MVN_PATH && process.env.MVN_PATH.trim() !== "") result.mvnPath = process.env.MVN_PATH.trim();
|
|
943
|
+
else try {
|
|
944
|
+
const mvnBin = execFileSync(isWin ? "where" : "which", isWin ? ["mvn.cmd"] : ["mvn"], {
|
|
945
|
+
encoding: "utf8",
|
|
946
|
+
timeout: 3e3,
|
|
947
|
+
windowsHide: true
|
|
948
|
+
}).split("\n")[0]?.trim();
|
|
949
|
+
if (mvnBin) {
|
|
950
|
+
const lastSep = Math.max(mvnBin.lastIndexOf("/"), mvnBin.lastIndexOf("\\"));
|
|
951
|
+
result.mvnPath = lastSep >= 0 ? mvnBin.substring(0, lastSep) : mvnBin;
|
|
952
|
+
}
|
|
953
|
+
} catch {}
|
|
954
|
+
writeJson(res, 200, result);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
],
|
|
958
|
+
upgrade: {
|
|
959
|
+
path: STARTER_API.logsWs,
|
|
960
|
+
handler: (req, socket, head) => {
|
|
961
|
+
if (!fence(req)) {
|
|
962
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
963
|
+
socket.destroy();
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const instanceId = queryParam(new URL(req.url ?? "/", "http://localhost"), "instanceId");
|
|
967
|
+
if (instanceId === void 0 || instanceId === "") {
|
|
968
|
+
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
969
|
+
socket.destroy();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const instance = kernels.getInstance(instanceId);
|
|
973
|
+
if (instance === void 0) {
|
|
974
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
975
|
+
socket.destroy();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
logsWss.handleUpgrade(req, socket, head, (ws) => {
|
|
979
|
+
const logs = kernels.getLogs(instanceId);
|
|
980
|
+
ws.send(JSON.stringify({
|
|
981
|
+
type: "history",
|
|
982
|
+
entries: logs
|
|
983
|
+
}));
|
|
984
|
+
ws.send(JSON.stringify({
|
|
985
|
+
type: "status",
|
|
986
|
+
status: instance.status,
|
|
987
|
+
exitCode: instance.exitCode
|
|
988
|
+
}));
|
|
989
|
+
kernels.attach(instanceId, ws);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/index.ts
|
|
997
|
+
/** Plugin identity for cordis.yml rows / the bundle patch. */
|
|
998
|
+
const name = "dsh-better-sidebar-starter";
|
|
999
|
+
/** Services required before mounting: the webserver routes, the session
|
|
1000
|
+
* store (authoritative cwd for the path gate), and the web runtime's
|
|
1001
|
+
* trusted hosts (the /api gateway's trust source). */
|
|
1002
|
+
const inject = [
|
|
1003
|
+
"webServer",
|
|
1004
|
+
"sessions",
|
|
1005
|
+
"webRuntime"
|
|
1006
|
+
];
|
|
1007
|
+
/** Plugin body: mount the fenced starter routes and the process lifecycle. */
|
|
1008
|
+
function apply(ctx) {
|
|
1009
|
+
const fence = (req) => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts);
|
|
1010
|
+
const kernels = new ProcessManager();
|
|
1011
|
+
ctx.effect(() => {
|
|
1012
|
+
const { routes, upgrade } = makeRoutes({
|
|
1013
|
+
ctx,
|
|
1014
|
+
kernels,
|
|
1015
|
+
fence
|
|
1016
|
+
});
|
|
1017
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
1018
|
+
disposers.push(ctx.webServer.registerUpgrade(upgrade));
|
|
1019
|
+
return () => {
|
|
1020
|
+
for (const dispose of disposers) try {
|
|
1021
|
+
dispose();
|
|
1022
|
+
} catch {}
|
|
1023
|
+
};
|
|
1024
|
+
}, "dsh-better-sidebar-starter: routes");
|
|
1025
|
+
ctx.effect(() => () => {
|
|
1026
|
+
kernels.dispose();
|
|
1027
|
+
}, "dsh-better-sidebar-starter: teardown");
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
1030
|
+
export { apply, inject, name };
|