@mebular/dsh-plugin 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/LICENSE +21 -0
- package/README.md +334 -2
- package/assets/console-starmap.jpg +0 -0
- package/assets/dsh-panel.jpg +0 -0
- package/assets/icon-180.png +0 -0
- package/client.js +521 -0
- package/cordis.patch.yml +16 -0
- package/icon.svg +8 -0
- package/index.js +1393 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/screenshots.json +6 -0
package/index.js
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
// @mebular/dsh-plugin host plugin.
|
|
2
|
+
//
|
|
3
|
+
// Boundary: this file is pure ESM with Node built-ins only. It never imports
|
|
4
|
+
// @mebular/*, never reads Mebular configuration or secrets, and never writes to
|
|
5
|
+
// the Mebular home directory. The single external module is the official DSH
|
|
6
|
+
// MCP bridge peer, loaded lazily at apply time (never at module top level).
|
|
7
|
+
//
|
|
8
|
+
// Lifecycle:
|
|
9
|
+
// - `attach` (default): only probe an existing daemon. Never spawn or stop.
|
|
10
|
+
// - `managed`: reuse a healthy daemon; otherwise spawn `mebular serve` once
|
|
11
|
+
// and stop only that child on plugin dispose. This is a process fallback,
|
|
12
|
+
// not a service install, and never touches an external/service daemon.
|
|
13
|
+
//
|
|
14
|
+
// The MCP bridge mounts the official @deepseek-ai/dsh-mcp-client plugin on the
|
|
15
|
+
// same daemon URL the panel uses, so both follow one discovery order:
|
|
16
|
+
// explicit daemonUrl > MEBULAR_HTTP_PORT > <home>/runtime.json > 7331.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { isAbsolute, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const name = '@mebular/dsh-plugin';
|
|
24
|
+
|
|
25
|
+
export const EMBED_INFO_PATH = '/mebular/embed-info';
|
|
26
|
+
|
|
27
|
+
/** Console discovery contract version, shared with packages/mcp. */
|
|
28
|
+
export const EMBED_PROTOCOL = 1;
|
|
29
|
+
|
|
30
|
+
/** Browser global injected by the host through DSH's webServer.tapIndex. */
|
|
31
|
+
export const PANEL_GLOBAL = '__MEBULAR_DSH__';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PORT = 7331;
|
|
34
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 1200;
|
|
35
|
+
const DEFAULT_CACHE_TTL_MS = 1000;
|
|
36
|
+
const DEFAULT_MANAGED_START_TIMEOUT_MS = 20000;
|
|
37
|
+
const DEFAULT_MANAGED_POLL_INTERVAL_MS = 500;
|
|
38
|
+
const DEFAULT_MANAGED_KILL_GRACE_MS = 2000;
|
|
39
|
+
const DEFAULT_CLI_NAME = 'mebular';
|
|
40
|
+
const RUNTIME_FILE = 'runtime.json';
|
|
41
|
+
const MAX_LOG_LINE = 500;
|
|
42
|
+
|
|
43
|
+
/** Official DSH MCP bridge. Supplied by the DSH host as a peer dependency. */
|
|
44
|
+
export const MCP_CLIENT_PACKAGE = '@deepseek-ai/dsh-mcp-client';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse an absolute HTTP(S) URL and return its normalized href.
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @returns {string | null}
|
|
50
|
+
*/
|
|
51
|
+
function httpUrl(value) {
|
|
52
|
+
if (typeof value !== 'string' || value.trim().length === 0) return null;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(value.trim());
|
|
55
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
56
|
+
return url.href;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} value */
|
|
63
|
+
function isObject(value) {
|
|
64
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isLoopbackIpv4(host) {
|
|
68
|
+
const parts = host.split('.');
|
|
69
|
+
if (parts.length !== 4) return false;
|
|
70
|
+
if (!parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false;
|
|
71
|
+
return parts[0] === '127';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isLoopbackIpv6(host) {
|
|
75
|
+
if (host === '::1') return true;
|
|
76
|
+
if (!host.startsWith('::ffff:')) return false;
|
|
77
|
+
const tail = host.slice('::ffff:'.length);
|
|
78
|
+
if (isLoopbackIpv4(tail)) return true;
|
|
79
|
+
const groups = tail.split(':');
|
|
80
|
+
return groups.length === 2
|
|
81
|
+
&& groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))
|
|
82
|
+
&& (Number.parseInt(groups.map((group) => group.padStart(4, '0')).join(''), 16) >>> 24) === 127;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Strict hostname check for a loopback URL: exact localhost (or .localhost),
|
|
87
|
+
* 127.0.0.0/8, ::1, and IPv4-mapped loopback. No DNS resolution, no suffix
|
|
88
|
+
* tricks such as `127.example.com`.
|
|
89
|
+
* @param {string} hostname
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function isLoopbackHostname(hostname) {
|
|
93
|
+
if (typeof hostname !== 'string' || hostname.length === 0) return false;
|
|
94
|
+
const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
|
|
95
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
|
96
|
+
if (host.includes(':')) return isLoopbackIpv6(host);
|
|
97
|
+
return isLoopbackIpv4(host);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Valid MEBULAR_HTTP_PORT, or null. Kept separate from daemonPort() so the
|
|
102
|
+
* host can tell "port explicitly configured" from "use the default".
|
|
103
|
+
* @param {Record<string, string | undefined>} [env]
|
|
104
|
+
* @returns {number | null}
|
|
105
|
+
*/
|
|
106
|
+
function envPort(env = process.env) {
|
|
107
|
+
const raw = env?.MEBULAR_HTTP_PORT;
|
|
108
|
+
if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null;
|
|
109
|
+
const port = Number(raw);
|
|
110
|
+
return port > 0 && port <= 65535 ? port : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function defaultDaemonUrl(env = process.env) {
|
|
114
|
+
return `http://127.0.0.1:${envPort(env) ?? DEFAULT_PORT}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mebular home for runtime.json discovery only. This mirrors the CLI's
|
|
119
|
+
* MEBULAR_HOME default without reading config.json, identity, or secrets.
|
|
120
|
+
* @param {Record<string, string | undefined>} [env]
|
|
121
|
+
*/
|
|
122
|
+
function mebularHome(env = process.env) {
|
|
123
|
+
const raw = env?.MEBULAR_HOME;
|
|
124
|
+
if (typeof raw === 'string' && raw.trim().length > 0) return raw.trim();
|
|
125
|
+
return join(homedir(), '.mebular');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isProcessAlive(pid) {
|
|
129
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, 0);
|
|
132
|
+
return true;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// EPERM means the process exists but belongs to another user.
|
|
135
|
+
return error?.code !== 'ESRCH';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validRuntimeHost(host) {
|
|
140
|
+
if (typeof host !== 'string') return false;
|
|
141
|
+
const value = host.trim();
|
|
142
|
+
if (value.length === 0 || value.length > 255) return false;
|
|
143
|
+
if (/[\s/@?#]/.test(value)) return false;
|
|
144
|
+
if (value.includes(':')) return /^[0-9a-f:.%]+$/i.test(value);
|
|
145
|
+
return /^[A-Za-z0-9.-]+$/.test(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read <home>/runtime.json. `options.runtime` (including null) is an injection
|
|
150
|
+
* seam for tests: null disables discovery, an object supplies the document.
|
|
151
|
+
* @param {{
|
|
152
|
+
* runtime?: unknown,
|
|
153
|
+
* home?: string,
|
|
154
|
+
* env?: Record<string, string | undefined>,
|
|
155
|
+
* }} [options]
|
|
156
|
+
* @returns {Record<string, unknown> | null}
|
|
157
|
+
*/
|
|
158
|
+
function readRuntimeState(options = {}) {
|
|
159
|
+
const opts = isObject(options) ? options : {};
|
|
160
|
+
if (opts.runtime !== undefined) {
|
|
161
|
+
return isObject(opts.runtime) ? opts.runtime : null;
|
|
162
|
+
}
|
|
163
|
+
const env = isObject(opts.env) ? opts.env : process.env;
|
|
164
|
+
const home = typeof opts.home === 'string' && opts.home.length > 0 ? opts.home : mebularHome(env);
|
|
165
|
+
try {
|
|
166
|
+
const value = JSON.parse(readFileSync(join(home, RUNTIME_FILE), 'utf-8'));
|
|
167
|
+
return isObject(value) ? value : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build a daemon origin from a runtime.json document. A dead pid makes the file
|
|
175
|
+
* stale (ignored); malformed fields never throw.
|
|
176
|
+
* @param {unknown} record
|
|
177
|
+
* @param {{ pidAlive?: (pid: number) => boolean }} [options]
|
|
178
|
+
* @returns {string | null}
|
|
179
|
+
*/
|
|
180
|
+
export function runtimeDaemonUrl(record, options = {}) {
|
|
181
|
+
if (!isObject(record) || !Number.isInteger(record.pid) || record.pid <= 0) return null;
|
|
182
|
+
if (!isObject(record.http)) return null;
|
|
183
|
+
const host = typeof record.http.host === 'string' ? record.http.host.trim() : '';
|
|
184
|
+
const port = record.http.port;
|
|
185
|
+
if (!validRuntimeHost(host) || !Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
186
|
+
const pidAlive = typeof options.pidAlive === 'function' ? options.pidAlive : isProcessAlive;
|
|
187
|
+
try {
|
|
188
|
+
if (!pidAlive(record.pid)) return null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const href = httpUrl(`http://${host.includes(':') ? `[${host}]` : host}:${port}`);
|
|
193
|
+
if (href === null) return null;
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(href);
|
|
196
|
+
if (url.username !== '' || url.password !== '') return null;
|
|
197
|
+
return url.origin;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeDaemonUrl(value, fallback = defaultDaemonUrl()) {
|
|
204
|
+
const href = httpUrl(value);
|
|
205
|
+
if (href === null) return fallback;
|
|
206
|
+
const url = new URL(href);
|
|
207
|
+
if (url.username !== '' || url.password !== '') return fallback;
|
|
208
|
+
const pathname = url.pathname.replace(/\/+$/, '');
|
|
209
|
+
return `${url.origin}${pathname}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @param {unknown} value */
|
|
213
|
+
function describeValue(value) {
|
|
214
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
215
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value);
|
|
216
|
+
return typeof value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve plugin config. Invalid integration values fall back to safe defaults
|
|
221
|
+
* and are reported through the optional warning callback; they never fail DSH
|
|
222
|
+
* activation.
|
|
223
|
+
*
|
|
224
|
+
* daemonUrl discovery order: explicit config > MEBULAR_HTTP_PORT > runtime.json
|
|
225
|
+
* (Mebular home, pid must still be alive) > loopback:7331. Discovery never
|
|
226
|
+
* touches config.json, identity, or secrets.
|
|
227
|
+
*
|
|
228
|
+
* @param {unknown} input
|
|
229
|
+
* @param {{
|
|
230
|
+
* warn?: (message: string) => void,
|
|
231
|
+
* env?: Record<string, string | undefined>,
|
|
232
|
+
* home?: string,
|
|
233
|
+
* runtime?: unknown,
|
|
234
|
+
* pidAlive?: (pid: number) => boolean,
|
|
235
|
+
* } | ((message: string) => void)} [options]
|
|
236
|
+
* @returns {{ daemonUrl: string, lifecycle: 'attach' | 'managed', panel: boolean, cliPath?: string }}
|
|
237
|
+
*/
|
|
238
|
+
export function resolveConfig(input = {}, options = {}) {
|
|
239
|
+
const config = isObject(input) ? input : {};
|
|
240
|
+
const opts = isObject(options) ? options : {};
|
|
241
|
+
const env = isObject(opts.env) ? opts.env : process.env;
|
|
242
|
+
const warn = typeof options === 'function'
|
|
243
|
+
? options
|
|
244
|
+
: typeof opts.warn === 'function'
|
|
245
|
+
? opts.warn
|
|
246
|
+
: null;
|
|
247
|
+
const notify = (message) => {
|
|
248
|
+
if (warn === null) return;
|
|
249
|
+
try {
|
|
250
|
+
warn(message);
|
|
251
|
+
} catch {
|
|
252
|
+
// Logging must never fail plugin activation.
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let lifecycle = 'attach';
|
|
257
|
+
if (config.lifecycle !== undefined) {
|
|
258
|
+
const value = typeof config.lifecycle === 'string'
|
|
259
|
+
? config.lifecycle.trim().toLowerCase()
|
|
260
|
+
: config.lifecycle;
|
|
261
|
+
if (value === 'attach' || value === 'managed') lifecycle = value;
|
|
262
|
+
else notify(`invalid lifecycle ${describeValue(config.lifecycle)}; using "attach"`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let panel = true;
|
|
266
|
+
if (config.panel !== undefined) {
|
|
267
|
+
if (typeof config.panel === 'boolean') panel = config.panel;
|
|
268
|
+
else if (config.panel === 'on') panel = true;
|
|
269
|
+
else if (config.panel === 'off') panel = false;
|
|
270
|
+
else notify(`invalid panel ${describeValue(config.panel)}; using true`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let cliPath;
|
|
274
|
+
if (config.cliPath !== undefined) {
|
|
275
|
+
if (typeof config.cliPath === 'string' && config.cliPath.trim().length > 0) {
|
|
276
|
+
cliPath = config.cliPath.trim();
|
|
277
|
+
} else {
|
|
278
|
+
notify('invalid cliPath; ignoring it');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let explicitDaemonUrl = null;
|
|
283
|
+
if (config.daemonUrl !== undefined) {
|
|
284
|
+
const href = httpUrl(config.daemonUrl);
|
|
285
|
+
if (href === null) {
|
|
286
|
+
notify('invalid daemonUrl; using the loopback default');
|
|
287
|
+
} else {
|
|
288
|
+
const parsed = new URL(href);
|
|
289
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
290
|
+
notify('credentials in daemonUrl are not allowed; using the loopback default');
|
|
291
|
+
} else {
|
|
292
|
+
explicitDaemonUrl = normalizeDaemonUrl(config.daemonUrl, `http://127.0.0.1:${DEFAULT_PORT}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let daemonUrl;
|
|
298
|
+
if (explicitDaemonUrl !== null) {
|
|
299
|
+
daemonUrl = explicitDaemonUrl;
|
|
300
|
+
} else {
|
|
301
|
+
const port = envPort(env);
|
|
302
|
+
if (port !== null) {
|
|
303
|
+
daemonUrl = `http://127.0.0.1:${port}`;
|
|
304
|
+
} else {
|
|
305
|
+
const runtimeOptions = { env };
|
|
306
|
+
if (opts.home !== undefined) runtimeOptions.home = opts.home;
|
|
307
|
+
// An explicit `runtime` (even null) is a test seam that disables or
|
|
308
|
+
// supplies the document instead of reading <home>/runtime.json.
|
|
309
|
+
if (opts.runtime !== undefined) runtimeOptions.runtime = opts.runtime;
|
|
310
|
+
const runtime = readRuntimeState(runtimeOptions);
|
|
311
|
+
daemonUrl = runtimeDaemonUrl(runtime, { pidAlive: opts.pidAlive }) ?? `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @type {{ daemonUrl: string, lifecycle: 'attach' | 'managed', panel: boolean, cliPath?: string }} */
|
|
316
|
+
const resolved = {
|
|
317
|
+
daemonUrl,
|
|
318
|
+
lifecycle,
|
|
319
|
+
panel,
|
|
320
|
+
};
|
|
321
|
+
if (cliPath !== undefined) resolved.cliPath = cliPath;
|
|
322
|
+
return resolved;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Join a base URL and a path without dropping a base path or duplicating slashes.
|
|
327
|
+
* @param {string} base
|
|
328
|
+
* @param {string} [path]
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
export function joinUrl(base, path = '') {
|
|
332
|
+
const root = String(base ?? '').trim().replace(/\/+$/, '');
|
|
333
|
+
const suffix = String(path ?? '').trim().replace(/^\/+/, '');
|
|
334
|
+
if (suffix.length === 0) return root;
|
|
335
|
+
return `${root}/${suffix}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Whether a URL points at a loopback host (only http/https).
|
|
340
|
+
* @param {unknown} value
|
|
341
|
+
* @returns {boolean}
|
|
342
|
+
*/
|
|
343
|
+
export function isLoopbackUrl(value) {
|
|
344
|
+
const href = httpUrl(value);
|
|
345
|
+
if (href === null) return false;
|
|
346
|
+
try {
|
|
347
|
+
return isLoopbackHostname(new URL(href).hostname);
|
|
348
|
+
} catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether a socket peer address is loopback. This is the only gate for the
|
|
355
|
+
* HTTP route: remote clients receive 404, never a status document. Hostnames
|
|
356
|
+
* are not accepted here because `remoteAddress` is always an IP literal.
|
|
357
|
+
* @param {unknown} address
|
|
358
|
+
* @returns {boolean}
|
|
359
|
+
*/
|
|
360
|
+
export function isLoopbackAddress(address) {
|
|
361
|
+
if (typeof address !== 'string' || address.length === 0) return false;
|
|
362
|
+
const value = address.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
363
|
+
if (value.includes(':')) return isLoopbackIpv6(value);
|
|
364
|
+
return isLoopbackIpv4(value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Port a managed daemon should listen on, derived from daemonUrl.
|
|
369
|
+
* @param {unknown} daemonUrl
|
|
370
|
+
* @returns {number}
|
|
371
|
+
*/
|
|
372
|
+
export function daemonPortFromUrl(daemonUrl) {
|
|
373
|
+
const href = httpUrl(daemonUrl);
|
|
374
|
+
if (href === null) return DEFAULT_PORT;
|
|
375
|
+
const url = new URL(href);
|
|
376
|
+
if (url.port === '') return url.protocol === 'https:' ? 443 : 80;
|
|
377
|
+
const port = Number(url.port);
|
|
378
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_PORT;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Resolve the Mebular CLI without reading Mebular config or secrets.
|
|
383
|
+
*
|
|
384
|
+
* Order: explicit cliPath, MEBULAR_CLI, then `mebular` on PATH. Every
|
|
385
|
+
* candidate is checked with `exists`; the first existing file wins.
|
|
386
|
+
*
|
|
387
|
+
* @param {{
|
|
388
|
+
* cliPath?: unknown,
|
|
389
|
+
* env?: Record<string, string | undefined>,
|
|
390
|
+
* platform?: string,
|
|
391
|
+
* exists?: (file: string) => boolean,
|
|
392
|
+
* }} [options]
|
|
393
|
+
* @returns {string | null}
|
|
394
|
+
*/
|
|
395
|
+
export function resolveCliPath(options = {}) {
|
|
396
|
+
const opts = isObject(options) ? options : {};
|
|
397
|
+
const env = isObject(opts.env) ? opts.env : process.env;
|
|
398
|
+
const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
|
|
399
|
+
const exists = typeof opts.exists === 'function' ? opts.exists : existsSync;
|
|
400
|
+
const isWindows = platform === 'win32';
|
|
401
|
+
const extList = isWindows
|
|
402
|
+
? (typeof env.PATHEXT === 'string' ? env.PATHEXT.split(';') : ['.EXE', '.CMD', '.BAT', '.COM']).filter(Boolean)
|
|
403
|
+
: [''];
|
|
404
|
+
const searchDirs = String(env.PATH ?? '')
|
|
405
|
+
.split(isWindows ? ';' : ':')
|
|
406
|
+
.filter((dir) => dir.length > 0);
|
|
407
|
+
|
|
408
|
+
/** @type {Array<{ name: string, direct: boolean }>} */
|
|
409
|
+
const candidates = [];
|
|
410
|
+
const add = (value) => {
|
|
411
|
+
if (typeof value !== 'string') return;
|
|
412
|
+
const trimmed = value.trim();
|
|
413
|
+
if (trimmed.length === 0) return;
|
|
414
|
+
const direct = isAbsolute(trimmed)
|
|
415
|
+
|| trimmed.includes('/')
|
|
416
|
+
|| (isWindows && trimmed.includes('\\'));
|
|
417
|
+
candidates.push({ name: trimmed, direct });
|
|
418
|
+
};
|
|
419
|
+
add(opts.cliPath);
|
|
420
|
+
add(env.MEBULAR_CLI);
|
|
421
|
+
|
|
422
|
+
for (const candidate of candidates) {
|
|
423
|
+
if (candidate.direct) {
|
|
424
|
+
if (exists(candidate.name)) return candidate.name;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
for (const dir of searchDirs) {
|
|
428
|
+
for (const ext of extList) {
|
|
429
|
+
const file = join(dir, `${candidate.name}${ext}`);
|
|
430
|
+
if (exists(file)) return file;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (const dir of searchDirs) {
|
|
436
|
+
for (const ext of extList) {
|
|
437
|
+
const file = join(dir, `${DEFAULT_CLI_NAME}${ext}`);
|
|
438
|
+
if (exists(file)) return file;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Build the non-sensitive status document returned to the DSH panel.
|
|
446
|
+
*
|
|
447
|
+
* - `status` is `up`, `down`, or `starting` (managed daemon boot).
|
|
448
|
+
* - `mode` is the effective lifecycle; it degrades to `attach` when the
|
|
449
|
+
* managed CLI cannot be resolved.
|
|
450
|
+
* - `managed` is true only while this plugin owns the running child process.
|
|
451
|
+
* - `ok` means "ready to embed": status up, protocol 1, embedConsole, both
|
|
452
|
+
* sides loopback, and a valid loopback embed URL.
|
|
453
|
+
* - `embedUrl` is null unless `ok`; the panel must never frame a fallback URL.
|
|
454
|
+
* - `reason` is null when `ok`, otherwise a stable diagnostic code.
|
|
455
|
+
*
|
|
456
|
+
* @param {{
|
|
457
|
+
* daemonUrl?: string,
|
|
458
|
+
* reachable?: boolean,
|
|
459
|
+
* health?: Record<string, unknown> | null,
|
|
460
|
+
* embed?: Record<string, unknown> | null,
|
|
461
|
+
* status?: 'up' | 'down' | 'starting',
|
|
462
|
+
* mode?: 'attach' | 'managed',
|
|
463
|
+
* managed?: boolean,
|
|
464
|
+
* panel?: boolean,
|
|
465
|
+
* reason?: string | null,
|
|
466
|
+
* }} [input]
|
|
467
|
+
* @returns {{
|
|
468
|
+
* ok: boolean,
|
|
469
|
+
* status: 'up' | 'down' | 'starting',
|
|
470
|
+
* mode: 'attach' | 'managed',
|
|
471
|
+
* managed: boolean,
|
|
472
|
+
* panel: boolean,
|
|
473
|
+
* version: string | null,
|
|
474
|
+
* embedProtocol: number | null,
|
|
475
|
+
* consoleUrl: string,
|
|
476
|
+
* embedUrl: string | null,
|
|
477
|
+
* mcpUrl: string,
|
|
478
|
+
* loopback: boolean,
|
|
479
|
+
* reason: string | null,
|
|
480
|
+
* }}
|
|
481
|
+
*/
|
|
482
|
+
export function buildEmbedInfo(input = {}) {
|
|
483
|
+
const config = isObject(input) ? input : {};
|
|
484
|
+
const daemonUrl = resolveConfig({ daemonUrl: config.daemonUrl }).daemonUrl;
|
|
485
|
+
const reachable = config.reachable === true;
|
|
486
|
+
const health = isObject(config.health) ? config.health : null;
|
|
487
|
+
const embed = isObject(config.embed) ? config.embed : null;
|
|
488
|
+
const status = config.status === 'starting' || config.status === 'up' || config.status === 'down'
|
|
489
|
+
? config.status
|
|
490
|
+
: reachable ? 'up' : 'down';
|
|
491
|
+
const mode = config.mode === 'managed' ? 'managed' : 'attach';
|
|
492
|
+
const managed = config.managed === true;
|
|
493
|
+
const panel = config.panel !== false;
|
|
494
|
+
const reasonOverride = typeof config.reason === 'string' && config.reason.length > 0
|
|
495
|
+
? config.reason
|
|
496
|
+
: null;
|
|
497
|
+
|
|
498
|
+
const daemonLoopback = isLoopbackUrl(daemonUrl);
|
|
499
|
+
const remoteLoopback = embed?.loopback === true;
|
|
500
|
+
const loopback = daemonLoopback && remoteLoopback;
|
|
501
|
+
const embedProtocol = Number.isInteger(embed?.embedProtocol) ? embed.embedProtocol : null;
|
|
502
|
+
|
|
503
|
+
const remoteConsoleUrl = httpUrl(embed?.consoleUrl);
|
|
504
|
+
const remoteEmbedUrl = httpUrl(embed?.embedUrl);
|
|
505
|
+
const remoteMcpUrl = httpUrl(embed?.mcpUrl);
|
|
506
|
+
const remoteInvalid = (typeof embed?.consoleUrl === 'string' && !isLoopbackUrl(embed.consoleUrl))
|
|
507
|
+
|| (typeof embed?.embedUrl === 'string' && !isLoopbackUrl(embed.embedUrl));
|
|
508
|
+
|
|
509
|
+
const consoleUrl = isLoopbackUrl(remoteConsoleUrl) ? remoteConsoleUrl : joinUrl(daemonUrl, '/console');
|
|
510
|
+
const mcpUrl = remoteMcpUrl ?? joinUrl(daemonUrl, '/mcp');
|
|
511
|
+
const version = typeof health?.version === 'string' && health.version.length > 0
|
|
512
|
+
? health.version
|
|
513
|
+
: typeof embed?.version === 'string' && embed.version.length > 0
|
|
514
|
+
? embed.version
|
|
515
|
+
: null;
|
|
516
|
+
|
|
517
|
+
/** @type {string | null} */
|
|
518
|
+
let reason = null;
|
|
519
|
+
if (status === 'starting') {
|
|
520
|
+
reason = reasonOverride;
|
|
521
|
+
} else if (!reachable) {
|
|
522
|
+
reason = reasonOverride ?? (health === null ? 'daemon_unreachable' : 'daemon_unhealthy');
|
|
523
|
+
} else if (embed === null) {
|
|
524
|
+
reason = reasonOverride ?? 'embed_info_unavailable';
|
|
525
|
+
} else if (embed.ok !== true) {
|
|
526
|
+
reason = reasonOverride ?? 'embed_not_ok';
|
|
527
|
+
} else if (!daemonLoopback || !remoteLoopback) {
|
|
528
|
+
reason = reasonOverride ?? 'not_loopback';
|
|
529
|
+
} else if (embedProtocol !== EMBED_PROTOCOL) {
|
|
530
|
+
reason = reasonOverride ?? 'embed_protocol_unsupported';
|
|
531
|
+
} else if (embed.capabilities?.embedConsole !== true) {
|
|
532
|
+
reason = reasonOverride ?? 'embed_unsupported';
|
|
533
|
+
} else if (remoteInvalid) {
|
|
534
|
+
reason = reasonOverride ?? 'embed_url_invalid';
|
|
535
|
+
} else if (!isLoopbackUrl(remoteEmbedUrl)) {
|
|
536
|
+
reason = reasonOverride ?? 'embed_url_invalid';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
ok: status === 'up' && reason === null,
|
|
541
|
+
status,
|
|
542
|
+
mode,
|
|
543
|
+
managed,
|
|
544
|
+
panel,
|
|
545
|
+
version,
|
|
546
|
+
embedProtocol,
|
|
547
|
+
consoleUrl,
|
|
548
|
+
embedUrl: status === 'up' && reason === null ? remoteEmbedUrl : null,
|
|
549
|
+
mcpUrl,
|
|
550
|
+
loopback,
|
|
551
|
+
reason,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Fetch a JSON document with a short timeout. Never throws.
|
|
557
|
+
* @param {string} url
|
|
558
|
+
* @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} options
|
|
559
|
+
* @returns {Promise<{ ok: boolean, data?: unknown, status?: number, error?: string }>}
|
|
560
|
+
*/
|
|
561
|
+
async function readJson(url, { fetchImpl, timeoutMs }) {
|
|
562
|
+
if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch_unavailable' };
|
|
563
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
564
|
+
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
|
565
|
+
timer.unref?.();
|
|
566
|
+
try {
|
|
567
|
+
const response = await fetchImpl(url, {
|
|
568
|
+
method: 'GET',
|
|
569
|
+
cache: 'no-store',
|
|
570
|
+
headers: { accept: 'application/json' },
|
|
571
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
572
|
+
});
|
|
573
|
+
if (response?.ok === false) return { ok: false, status: response.status, error: 'http_error' };
|
|
574
|
+
return { ok: true, data: await response.json() };
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
error: error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'network_error',
|
|
579
|
+
};
|
|
580
|
+
} finally {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Probe a Mebular daemon: /healthz decides reachability, /app/embed/info
|
|
587
|
+
* supplies the embed contract. Both run in parallel under the same timeout.
|
|
588
|
+
* @param {{
|
|
589
|
+
* daemonUrl?: string,
|
|
590
|
+
* fetchImpl?: typeof fetch,
|
|
591
|
+
* timeoutMs?: number,
|
|
592
|
+
* }} [options]
|
|
593
|
+
* @returns {Promise<{
|
|
594
|
+
* reachable: boolean,
|
|
595
|
+
* health: Record<string, unknown> | null,
|
|
596
|
+
* embed: Record<string, unknown> | null,
|
|
597
|
+
* }>}
|
|
598
|
+
*/
|
|
599
|
+
export async function probeDaemon(options = {}) {
|
|
600
|
+
const daemonUrl = resolveConfig({ daemonUrl: options.daemonUrl }).daemonUrl;
|
|
601
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
602
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_PROBE_TIMEOUT_MS;
|
|
603
|
+
const [healthResult, embedResult] = await Promise.all([
|
|
604
|
+
readJson(joinUrl(daemonUrl, '/healthz'), { fetchImpl, timeoutMs }),
|
|
605
|
+
readJson(joinUrl(daemonUrl, '/app/embed/info'), { fetchImpl, timeoutMs }),
|
|
606
|
+
]);
|
|
607
|
+
const health = healthResult.ok && isObject(healthResult.data) ? healthResult.data : null;
|
|
608
|
+
const embed = embedResult.ok && isObject(embedResult.data) ? embedResult.data : null;
|
|
609
|
+
const healthy = health !== null && (health.status === 'ok' || health.ok === true);
|
|
610
|
+
return { reachable: healthy, health, embed };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const LOG_SECRET_KEY = '[A-Za-z0-9_.-]*(?:token|secret|password|passwd|api[_-]?key|apikey)[A-Za-z0-9_.-]*';
|
|
614
|
+
const LOG_SECRET_KV = new RegExp(
|
|
615
|
+
`(["']?)(${LOG_SECRET_KEY})\\1(\\s*[:=]\\s*)("[^"]*"|'[^']*'|[^\\s,;&]+)`,
|
|
616
|
+
'gi',
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Redact secrets before a managed-child log line reaches the DSH logger.
|
|
621
|
+
* Values of token/secret/password/api_key and Authorization headers are
|
|
622
|
+
* replaced with <redacted>; the key name and any Bearer/Basic scheme remain.
|
|
623
|
+
* The caller may supply the CLI path and Mebular home to strip those too.
|
|
624
|
+
*
|
|
625
|
+
* @param {unknown} line
|
|
626
|
+
* @param {{ home?: string, cliPath?: string }} [options]
|
|
627
|
+
* @returns {string}
|
|
628
|
+
*/
|
|
629
|
+
export function redactLogLine(line, options = {}) {
|
|
630
|
+
const opts = isObject(options) ? options : {};
|
|
631
|
+
let text = typeof line === 'string' ? line : String(line ?? '');
|
|
632
|
+
if (typeof opts.home === 'string' && opts.home.length > 1) {
|
|
633
|
+
text = text.split(opts.home).join('<home>');
|
|
634
|
+
}
|
|
635
|
+
if (typeof opts.cliPath === 'string' && opts.cliPath.length > 0) {
|
|
636
|
+
text = text.split(opts.cliPath).join('<cli>');
|
|
637
|
+
}
|
|
638
|
+
text = text.replace(
|
|
639
|
+
/(\bAuthorization["']?\s*[:=]\s*)(Bearer|Basic)\s+[^\s,;"']+/gi,
|
|
640
|
+
(_match, prefix, scheme) => `${prefix}${scheme} <redacted>`,
|
|
641
|
+
);
|
|
642
|
+
text = text.replace(
|
|
643
|
+
/(\bAuthorization["']?\s*[:=]\s*)(?!(?:Bearer|Basic)\b)[^\s,;"']+/gi,
|
|
644
|
+
'$1<redacted>',
|
|
645
|
+
);
|
|
646
|
+
text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer <redacted>');
|
|
647
|
+
text = text.replace(LOG_SECRET_KV, (_match, quote, key, separator, value) => {
|
|
648
|
+
const prefix = `${quote}${key}${quote}${separator}`;
|
|
649
|
+
if (value.startsWith('"')) return `${prefix}"<redacted>"`;
|
|
650
|
+
if (value.startsWith("'")) return `${prefix}'<redacted>'`;
|
|
651
|
+
return `${prefix}<redacted>`;
|
|
652
|
+
});
|
|
653
|
+
if (text.length > MAX_LOG_LINE) text = `${text.slice(0, MAX_LOG_LINE)}…`;
|
|
654
|
+
return text;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function safeErrorCode(error) {
|
|
658
|
+
const code = error?.code;
|
|
659
|
+
return typeof code === 'string' && /^[A-Za-z0-9_.-]+$/.test(code) ? code : 'error';
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function safeLog(logger, level, message) {
|
|
663
|
+
try {
|
|
664
|
+
const fn = logger?.[level];
|
|
665
|
+
if (typeof fn === 'function') fn.call(logger, message);
|
|
666
|
+
} catch {
|
|
667
|
+
// Logging must never fail plugin activation or disposal.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Create the daemon lifecycle state machine used by the host plugin.
|
|
673
|
+
*
|
|
674
|
+
* attach: probes only. managed: probes once, reuses a healthy daemon, or
|
|
675
|
+
* spawns the resolved CLI exactly once and waits for /healthz with a bound.
|
|
676
|
+
* dispose() sends SIGTERM only to a child this lifecycle started.
|
|
677
|
+
*
|
|
678
|
+
* @param {{
|
|
679
|
+
* config?: unknown,
|
|
680
|
+
* warn?: (message: string) => void,
|
|
681
|
+
* logger?: { info?: (message: string) => void, warn?: (message: string) => void },
|
|
682
|
+
* probe?: typeof probeDaemon,
|
|
683
|
+
* fetchImpl?: typeof fetch,
|
|
684
|
+
* spawnFn?: typeof spawn,
|
|
685
|
+
* resolveCli?: (context: { config: ReturnType<typeof resolveConfig> }) => string | null,
|
|
686
|
+
* exists?: (file: string) => boolean,
|
|
687
|
+
* env?: Record<string, string | undefined>,
|
|
688
|
+
* home?: string,
|
|
689
|
+
* runtime?: unknown,
|
|
690
|
+
* pidAlive?: (pid: number) => boolean,
|
|
691
|
+
* platform?: string,
|
|
692
|
+
* now?: () => number,
|
|
693
|
+
* sleep?: (ms: number) => Promise<unknown>,
|
|
694
|
+
* timeoutMs?: number,
|
|
695
|
+
* cacheTtlMs?: number,
|
|
696
|
+
* startTimeoutMs?: number,
|
|
697
|
+
* pollIntervalMs?: number,
|
|
698
|
+
* killGraceMs?: number,
|
|
699
|
+
* }} [options]
|
|
700
|
+
* @returns {{
|
|
701
|
+
* start: () => Promise<{
|
|
702
|
+
* status: 'up' | 'down' | 'starting',
|
|
703
|
+
* mode: 'attach' | 'managed',
|
|
704
|
+
* managed: boolean,
|
|
705
|
+
* reason: string | null,
|
|
706
|
+
* reachable: boolean,
|
|
707
|
+
* health: Record<string, unknown> | null,
|
|
708
|
+
* embed: Record<string, unknown> | null,
|
|
709
|
+
* }>,
|
|
710
|
+
* inspect: () => Promise<{
|
|
711
|
+
* status: 'up' | 'down' | 'starting',
|
|
712
|
+
* mode: 'attach' | 'managed',
|
|
713
|
+
* managed: boolean,
|
|
714
|
+
* reason: string | null,
|
|
715
|
+
* reachable: boolean,
|
|
716
|
+
* health: Record<string, unknown> | null,
|
|
717
|
+
* embed: Record<string, unknown> | null,
|
|
718
|
+
* }>,
|
|
719
|
+
* refresh: () => Promise<unknown>,
|
|
720
|
+
* dispose: () => void,
|
|
721
|
+
* getState: () => {
|
|
722
|
+
* status: 'up' | 'down' | 'starting',
|
|
723
|
+
* mode: 'attach' | 'managed',
|
|
724
|
+
* managed: boolean,
|
|
725
|
+
* reason: string | null,
|
|
726
|
+
* reachable: boolean,
|
|
727
|
+
* health: Record<string, unknown> | null,
|
|
728
|
+
* embed: Record<string, unknown> | null,
|
|
729
|
+
* },
|
|
730
|
+
* }}
|
|
731
|
+
*/
|
|
732
|
+
export function createDaemonLifecycle(options = {}) {
|
|
733
|
+
const opts = isObject(options) ? options : {};
|
|
734
|
+
const env = isObject(opts.env) ? opts.env : process.env;
|
|
735
|
+
const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
|
|
736
|
+
const configOptions = { warn: opts.warn, env };
|
|
737
|
+
if (opts.home !== undefined) configOptions.home = opts.home;
|
|
738
|
+
if (opts.runtime !== undefined) configOptions.runtime = opts.runtime;
|
|
739
|
+
if (opts.pidAlive !== undefined) configOptions.pidAlive = opts.pidAlive;
|
|
740
|
+
const config = resolveConfig(opts.config, configOptions);
|
|
741
|
+
const probe = typeof opts.probe === 'function' ? opts.probe : probeDaemon;
|
|
742
|
+
const spawnFn = typeof opts.spawnFn === 'function' ? opts.spawnFn : spawn;
|
|
743
|
+
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
744
|
+
const sleep = typeof opts.sleep === 'function'
|
|
745
|
+
? opts.sleep
|
|
746
|
+
: (ms) => new Promise((resolve) => {
|
|
747
|
+
const timer = setTimeout(resolve, ms);
|
|
748
|
+
timer.unref?.();
|
|
749
|
+
});
|
|
750
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
751
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_PROBE_TIMEOUT_MS;
|
|
752
|
+
const cacheTtlMs = Number.isFinite(opts.cacheTtlMs) ? opts.cacheTtlMs : DEFAULT_CACHE_TTL_MS;
|
|
753
|
+
const startTimeoutMs = Number.isFinite(opts.startTimeoutMs)
|
|
754
|
+
? opts.startTimeoutMs
|
|
755
|
+
: DEFAULT_MANAGED_START_TIMEOUT_MS;
|
|
756
|
+
const pollIntervalMs = Number.isFinite(opts.pollIntervalMs)
|
|
757
|
+
? opts.pollIntervalMs
|
|
758
|
+
: DEFAULT_MANAGED_POLL_INTERVAL_MS;
|
|
759
|
+
const killGraceMs = Number.isFinite(opts.killGraceMs)
|
|
760
|
+
? Math.max(0, opts.killGraceMs)
|
|
761
|
+
: DEFAULT_MANAGED_KILL_GRACE_MS;
|
|
762
|
+
const logHome = typeof opts.home === 'string' && opts.home.length > 0 ? opts.home : mebularHome(env);
|
|
763
|
+
const logger = isObject(opts.logger) ? opts.logger : null;
|
|
764
|
+
const resolveCli = typeof opts.resolveCli === 'function'
|
|
765
|
+
? opts.resolveCli
|
|
766
|
+
: () => resolveCliPath({
|
|
767
|
+
cliPath: config.cliPath,
|
|
768
|
+
env,
|
|
769
|
+
platform,
|
|
770
|
+
exists: opts.exists,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
let mode = config.lifecycle;
|
|
774
|
+
let phase = config.lifecycle === 'managed' ? 'starting' : 'down';
|
|
775
|
+
let managed = false;
|
|
776
|
+
let child = null;
|
|
777
|
+
let childState = 'none';
|
|
778
|
+
/** @type {Promise<unknown> | null} */
|
|
779
|
+
let terminating = null;
|
|
780
|
+
let terminatingChild = null;
|
|
781
|
+
let failureReason = null;
|
|
782
|
+
let health = null;
|
|
783
|
+
let embed = null;
|
|
784
|
+
let reachable = false;
|
|
785
|
+
let lastProbeAt = -Infinity;
|
|
786
|
+
/** @type {Promise<unknown> | null} */
|
|
787
|
+
let probing = null;
|
|
788
|
+
/** @type {Promise<unknown> | null} */
|
|
789
|
+
let startPromise = null;
|
|
790
|
+
let disposed = false;
|
|
791
|
+
|
|
792
|
+
function log(level, message) {
|
|
793
|
+
safeLog(logger, level, message);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function currentSnapshot() {
|
|
797
|
+
return {
|
|
798
|
+
status: phase,
|
|
799
|
+
mode,
|
|
800
|
+
managed: managed && childState === 'running' && !disposed,
|
|
801
|
+
reason: phase === 'down' ? failureReason : null,
|
|
802
|
+
reachable: phase === 'up' ? true : reachable,
|
|
803
|
+
health,
|
|
804
|
+
embed,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function applyProbe(result) {
|
|
809
|
+
const value = isObject(result) ? result : {};
|
|
810
|
+
health = isObject(value.health) ? value.health : null;
|
|
811
|
+
embed = isObject(value.embed) ? value.embed : null;
|
|
812
|
+
reachable = value.reachable === true;
|
|
813
|
+
lastProbeAt = now();
|
|
814
|
+
|
|
815
|
+
if (reachable) {
|
|
816
|
+
phase = 'up';
|
|
817
|
+
failureReason = null;
|
|
818
|
+
if (childState !== 'running') managed = false;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (phase === 'starting') return;
|
|
822
|
+
phase = 'down';
|
|
823
|
+
if (childState === 'exited' && mode === 'managed') {
|
|
824
|
+
failureReason = 'managed_start_failed';
|
|
825
|
+
} else if (failureReason !== 'managed_cli_missing' && failureReason !== 'managed_start_failed') {
|
|
826
|
+
failureReason = null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function runProbe() {
|
|
831
|
+
try {
|
|
832
|
+
const result = await probe({ daemonUrl: config.daemonUrl, fetchImpl, timeoutMs });
|
|
833
|
+
return isObject(result) ? result : { reachable: false, health: null, embed: null };
|
|
834
|
+
} catch {
|
|
835
|
+
return { reachable: false, health: null, embed: null };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function refresh() {
|
|
840
|
+
if (probing !== null) return probing;
|
|
841
|
+
probing = (async () => {
|
|
842
|
+
const result = await runProbe();
|
|
843
|
+
if (!disposed) applyProbe(result);
|
|
844
|
+
return currentSnapshot();
|
|
845
|
+
})().finally(() => {
|
|
846
|
+
probing = null;
|
|
847
|
+
});
|
|
848
|
+
return probing;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function wireOutput(stream, level, cliPath) {
|
|
852
|
+
if (stream === null || typeof stream !== 'object' || typeof stream.on !== 'function') return;
|
|
853
|
+
try {
|
|
854
|
+
stream.on('data', (chunk) => {
|
|
855
|
+
const text = String(chunk ?? '');
|
|
856
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 20);
|
|
857
|
+
for (const line of lines) {
|
|
858
|
+
log(level, `managed daemon: ${redactLogLine(line, { home: logHome, cliPath })}`);
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
} catch {
|
|
862
|
+
// A broken stream must not break the lifecycle.
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function startChild(cliPath) {
|
|
867
|
+
const port = daemonPortFromUrl(config.daemonUrl);
|
|
868
|
+
const args = ['serve', '--port', String(port)];
|
|
869
|
+
// Node cannot execute .cmd/.bat directly on Windows. The args are fixed and
|
|
870
|
+
// the CLI path comes from local config/PATH, so shell quoting is not an
|
|
871
|
+
// injection surface. Other platforms keep direct exec.
|
|
872
|
+
const shell = platform === 'win32' && /\.(?:cmd|bat)$/i.test(cliPath);
|
|
873
|
+
let proc;
|
|
874
|
+
try {
|
|
875
|
+
// Inherit the process environment: the Mebular CLI resolves its own home.
|
|
876
|
+
proc = spawnFn(cliPath, args, {
|
|
877
|
+
env: { ...env },
|
|
878
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
879
|
+
windowsHide: true,
|
|
880
|
+
...(shell ? { shell: true } : {}),
|
|
881
|
+
});
|
|
882
|
+
} catch (error) {
|
|
883
|
+
log('warn', `managed daemon failed to spawn (${safeErrorCode(error)})`);
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
if (proc === null || typeof proc !== 'object') {
|
|
887
|
+
log('warn', 'managed daemon failed to spawn (no_child_process)');
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
child = proc;
|
|
892
|
+
childState = 'running';
|
|
893
|
+
managed = true;
|
|
894
|
+
// Never log the CLI path or the Mebular home.
|
|
895
|
+
log('info', `managed daemon started on port ${port}`);
|
|
896
|
+
|
|
897
|
+
const markExited = (event, detail) => {
|
|
898
|
+
if (childState === 'exited') return;
|
|
899
|
+
childState = 'exited';
|
|
900
|
+
managed = false;
|
|
901
|
+
if (disposed) return;
|
|
902
|
+
if (phase !== 'starting') phase = 'down';
|
|
903
|
+
log('warn', `managed daemon ${event}${detail ? `: ${detail}` : ''}`);
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
try {
|
|
907
|
+
proc.once?.('error', (error) => markExited('failed', safeErrorCode(error)));
|
|
908
|
+
proc.once?.('exit', (code, signal) => {
|
|
909
|
+
markExited('exited', signal ? `signal ${signal}` : `code ${code}`);
|
|
910
|
+
});
|
|
911
|
+
} catch {
|
|
912
|
+
// Event wiring failure is handled by the health wait and timeout.
|
|
913
|
+
}
|
|
914
|
+
wireOutput(proc.stdout, 'info', cliPath);
|
|
915
|
+
wireOutput(proc.stderr, 'warn', cliPath);
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function signalChild(proc, signal) {
|
|
920
|
+
try {
|
|
921
|
+
proc.kill(signal);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
log('warn', `failed to signal managed daemon (${safeErrorCode(error)})`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Terminate a child this lifecycle started: SIGTERM, then SIGKILL after the
|
|
929
|
+
* grace period. Resolves when the process exits or the escalation completes.
|
|
930
|
+
* @param {unknown} proc
|
|
931
|
+
*/
|
|
932
|
+
function terminateChild(proc) {
|
|
933
|
+
if (proc === null || typeof proc !== 'object') return Promise.resolve();
|
|
934
|
+
if (terminatingChild === proc && terminating !== null) return terminating;
|
|
935
|
+
terminatingChild = proc;
|
|
936
|
+
terminating = new Promise((resolve) => {
|
|
937
|
+
let settled = false;
|
|
938
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
939
|
+
let timer = null;
|
|
940
|
+
const finish = () => {
|
|
941
|
+
if (settled) return;
|
|
942
|
+
settled = true;
|
|
943
|
+
if (timer !== null) clearTimeout(timer);
|
|
944
|
+
try {
|
|
945
|
+
proc.removeListener?.('exit', onExit);
|
|
946
|
+
} catch {
|
|
947
|
+
// Listener cleanup must never block disposal.
|
|
948
|
+
}
|
|
949
|
+
resolve();
|
|
950
|
+
};
|
|
951
|
+
const onExit = () => finish();
|
|
952
|
+
try {
|
|
953
|
+
proc.once?.('exit', onExit);
|
|
954
|
+
} catch {
|
|
955
|
+
// The timeout below still escalates.
|
|
956
|
+
}
|
|
957
|
+
signalChild(proc, 'SIGTERM');
|
|
958
|
+
timer = setTimeout(() => {
|
|
959
|
+
if (settled) return;
|
|
960
|
+
log('warn', `managed daemon did not exit after SIGTERM within ${killGraceMs}ms; sending SIGKILL`);
|
|
961
|
+
signalChild(proc, 'SIGKILL');
|
|
962
|
+
finish();
|
|
963
|
+
}, killGraceMs);
|
|
964
|
+
timer.unref?.();
|
|
965
|
+
});
|
|
966
|
+
return terminating;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function stopOwnedChild() {
|
|
970
|
+
const proc = child;
|
|
971
|
+
if (proc === null) return;
|
|
972
|
+
if (childState !== 'running') {
|
|
973
|
+
child = null;
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
await terminateChild(proc);
|
|
977
|
+
if (child === proc) {
|
|
978
|
+
child = null;
|
|
979
|
+
childState = 'exited';
|
|
980
|
+
}
|
|
981
|
+
managed = false;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function waitForManagedHealth() {
|
|
985
|
+
const deadline = now() + startTimeoutMs;
|
|
986
|
+
while (!disposed) {
|
|
987
|
+
const result = await runProbe();
|
|
988
|
+
if (disposed) return;
|
|
989
|
+
applyProbe(result);
|
|
990
|
+
if (reachable) return;
|
|
991
|
+
if (childState === 'exited') {
|
|
992
|
+
await stopOwnedChild();
|
|
993
|
+
if (disposed) return;
|
|
994
|
+
phase = 'down';
|
|
995
|
+
failureReason = 'managed_start_failed';
|
|
996
|
+
log('warn', 'managed daemon exited before becoming healthy');
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (now() >= deadline) {
|
|
1000
|
+
log('warn', `managed daemon did not become healthy within ${startTimeoutMs}ms`);
|
|
1001
|
+
// Only the child spawned above is stopped; an external daemon is never
|
|
1002
|
+
// touched here because child is null in attach-after-reuse paths.
|
|
1003
|
+
await stopOwnedChild();
|
|
1004
|
+
if (disposed) return;
|
|
1005
|
+
phase = 'down';
|
|
1006
|
+
failureReason = 'managed_start_failed';
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
await sleep(pollIntervalMs);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function ensureManaged() {
|
|
1014
|
+
const initial = await runProbe();
|
|
1015
|
+
if (disposed) return;
|
|
1016
|
+
applyProbe(initial);
|
|
1017
|
+
if (reachable) {
|
|
1018
|
+
log('info', 'reusing an existing Mebular daemon');
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let cli = null;
|
|
1023
|
+
try {
|
|
1024
|
+
cli = resolveCli({ config });
|
|
1025
|
+
} catch {
|
|
1026
|
+
log('warn', 'managed CLI resolution failed; falling back to attach');
|
|
1027
|
+
}
|
|
1028
|
+
const cliPath = typeof cli === 'string' && cli.trim().length > 0 ? cli.trim() : null;
|
|
1029
|
+
if (cliPath === null) {
|
|
1030
|
+
mode = 'attach';
|
|
1031
|
+
phase = 'down';
|
|
1032
|
+
failureReason = 'managed_cli_missing';
|
|
1033
|
+
log('warn', 'managed lifecycle requested but no Mebular CLI was found; falling back to attach');
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
phase = 'starting';
|
|
1038
|
+
failureReason = null;
|
|
1039
|
+
if (!startChild(cliPath)) {
|
|
1040
|
+
phase = 'down';
|
|
1041
|
+
failureReason = 'managed_start_failed';
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
await waitForManagedHealth();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function start() {
|
|
1048
|
+
if (startPromise !== null) return startPromise;
|
|
1049
|
+
if (disposed) return Promise.resolve(currentSnapshot());
|
|
1050
|
+
startPromise = (async () => {
|
|
1051
|
+
try {
|
|
1052
|
+
if (config.lifecycle === 'managed') await ensureManaged();
|
|
1053
|
+
else await refresh();
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
log('warn', `lifecycle start failed (${safeErrorCode(error)})`);
|
|
1056
|
+
// A thrown start path must not leave an owned child behind. This never
|
|
1057
|
+
// touches a daemon the plugin did not spawn.
|
|
1058
|
+
await stopOwnedChild();
|
|
1059
|
+
phase = 'down';
|
|
1060
|
+
failureReason = mode === 'managed' ? 'managed_start_failed' : null;
|
|
1061
|
+
}
|
|
1062
|
+
return currentSnapshot();
|
|
1063
|
+
})();
|
|
1064
|
+
return startPromise;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function inspect() {
|
|
1068
|
+
if (disposed) return currentSnapshot();
|
|
1069
|
+
if (phase === 'starting') return currentSnapshot();
|
|
1070
|
+
if (probing !== null) {
|
|
1071
|
+
await probing;
|
|
1072
|
+
return currentSnapshot();
|
|
1073
|
+
}
|
|
1074
|
+
if (now() - lastProbeAt < cacheTtlMs) return currentSnapshot();
|
|
1075
|
+
await refresh();
|
|
1076
|
+
return currentSnapshot();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function dispose() {
|
|
1080
|
+
const proc = child;
|
|
1081
|
+
const running = proc !== null && childState === 'running';
|
|
1082
|
+
disposed = true;
|
|
1083
|
+
child = null;
|
|
1084
|
+
childState = 'exited';
|
|
1085
|
+
managed = false;
|
|
1086
|
+
phase = 'down';
|
|
1087
|
+
// SIGTERM now, SIGKILL after killGraceMs. termination is intentionally not
|
|
1088
|
+
// awaited: DSH disposal is synchronous, and the timer still escalates.
|
|
1089
|
+
if (running) void terminateChild(proc);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
start,
|
|
1094
|
+
inspect,
|
|
1095
|
+
refresh,
|
|
1096
|
+
dispose,
|
|
1097
|
+
getState: currentSnapshot,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Create the exact-path HTTP handler. With a lifecycle, status comes from the
|
|
1103
|
+
* lifecycle state machine; without one (tests/tools), the read-only probe and
|
|
1104
|
+
* its 1s cache are used directly.
|
|
1105
|
+
* @param {{
|
|
1106
|
+
* config?: unknown,
|
|
1107
|
+
* fetchImpl?: typeof fetch,
|
|
1108
|
+
* probe?: typeof probeDaemon,
|
|
1109
|
+
* lifecycle?: ReturnType<typeof createDaemonLifecycle> | null,
|
|
1110
|
+
* now?: () => number,
|
|
1111
|
+
* cacheTtlMs?: number,
|
|
1112
|
+
* timeoutMs?: number,
|
|
1113
|
+
* env?: Record<string, string | undefined>,
|
|
1114
|
+
* home?: string,
|
|
1115
|
+
* runtime?: unknown,
|
|
1116
|
+
* pidAlive?: (pid: number) => boolean,
|
|
1117
|
+
* }} [options]
|
|
1118
|
+
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>}
|
|
1119
|
+
*/
|
|
1120
|
+
export function createEmbedInfoHandler(options = {}) {
|
|
1121
|
+
const configOptions = { env: options.env, runtime: options.runtime, home: options.home, pidAlive: options.pidAlive };
|
|
1122
|
+
const config = resolveConfig(options.config, configOptions);
|
|
1123
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1124
|
+
const probe = typeof options.probe === 'function' ? options.probe : probeDaemon;
|
|
1125
|
+
const lifecycle = options.lifecycle !== null && typeof options.lifecycle?.inspect === 'function'
|
|
1126
|
+
? options.lifecycle
|
|
1127
|
+
: null;
|
|
1128
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
1129
|
+
const cacheTtlMs = Number.isFinite(options.cacheTtlMs) ? options.cacheTtlMs : DEFAULT_CACHE_TTL_MS;
|
|
1130
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_PROBE_TIMEOUT_MS;
|
|
1131
|
+
|
|
1132
|
+
/** @type {ReturnType<typeof buildEmbedInfo> | null} */
|
|
1133
|
+
let cached = null;
|
|
1134
|
+
let cachedAt = 0;
|
|
1135
|
+
/** @type {Promise<ReturnType<typeof buildEmbedInfo>> | null} */
|
|
1136
|
+
let inflight = null;
|
|
1137
|
+
|
|
1138
|
+
async function readInfo() {
|
|
1139
|
+
if (lifecycle !== null) {
|
|
1140
|
+
const snapshot = await lifecycle.inspect();
|
|
1141
|
+
return buildEmbedInfo({
|
|
1142
|
+
daemonUrl: config.daemonUrl,
|
|
1143
|
+
reachable: snapshot?.reachable === true,
|
|
1144
|
+
health: isObject(snapshot?.health) ? snapshot.health : null,
|
|
1145
|
+
embed: isObject(snapshot?.embed) ? snapshot.embed : null,
|
|
1146
|
+
status: snapshot?.status,
|
|
1147
|
+
mode: snapshot?.mode,
|
|
1148
|
+
managed: snapshot?.managed === true,
|
|
1149
|
+
panel: config.panel,
|
|
1150
|
+
reason: typeof snapshot?.reason === 'string' ? snapshot.reason : null,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (cached !== null && now() - cachedAt < cacheTtlMs) return cached;
|
|
1155
|
+
if (inflight === null) {
|
|
1156
|
+
inflight = Promise.resolve()
|
|
1157
|
+
.then(() => probe({ daemonUrl: config.daemonUrl, fetchImpl, timeoutMs }))
|
|
1158
|
+
.then((result) => buildEmbedInfo({
|
|
1159
|
+
daemonUrl: config.daemonUrl,
|
|
1160
|
+
reachable: result?.reachable === true,
|
|
1161
|
+
health: isObject(result?.health) ? result.health : null,
|
|
1162
|
+
embed: isObject(result?.embed) ? result.embed : null,
|
|
1163
|
+
panel: config.panel,
|
|
1164
|
+
}))
|
|
1165
|
+
.catch(() => buildEmbedInfo({ daemonUrl: config.daemonUrl, reachable: false, panel: config.panel }))
|
|
1166
|
+
.then((value) => {
|
|
1167
|
+
cached = value;
|
|
1168
|
+
cachedAt = now();
|
|
1169
|
+
return value;
|
|
1170
|
+
})
|
|
1171
|
+
.finally(() => {
|
|
1172
|
+
inflight = null;
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
return inflight;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function sendJson(res, status, value, extraHeaders = {}) {
|
|
1179
|
+
if (res === null || typeof res !== 'object' || typeof res.end !== 'function') return;
|
|
1180
|
+
res.statusCode = status;
|
|
1181
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1182
|
+
res.setHeader('cache-control', 'no-store');
|
|
1183
|
+
for (const [key, headerValue] of Object.entries(extraHeaders)) res.setHeader(key, headerValue);
|
|
1184
|
+
res.end(JSON.stringify(value));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return async function handleEmbedInfo(req, res) {
|
|
1188
|
+
// Loopback first: a remote peer gets 404 for every method, not a 405 that
|
|
1189
|
+
// would confirm the route exists.
|
|
1190
|
+
if (!isLoopbackAddress(req?.socket?.remoteAddress)) {
|
|
1191
|
+
if (res !== null && typeof res === 'object' && typeof res.end === 'function') {
|
|
1192
|
+
res.statusCode = 404;
|
|
1193
|
+
res.end();
|
|
1194
|
+
}
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (req.method !== 'GET') {
|
|
1198
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' }, { allow: 'GET' });
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
let info;
|
|
1202
|
+
try {
|
|
1203
|
+
info = await readInfo();
|
|
1204
|
+
} catch {
|
|
1205
|
+
info = buildEmbedInfo({ daemonUrl: config.daemonUrl, reachable: false, panel: config.panel });
|
|
1206
|
+
}
|
|
1207
|
+
sendJson(res, 200, info);
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* JSON embedded in an inline <script>. `<` is escaped so a string value can
|
|
1213
|
+
* never close the script tag; U+2028/U+2029 are escaped for older parsers.
|
|
1214
|
+
* @param {unknown} value
|
|
1215
|
+
* @returns {string}
|
|
1216
|
+
*/
|
|
1217
|
+
export function scriptJson(value) {
|
|
1218
|
+
let json;
|
|
1219
|
+
try {
|
|
1220
|
+
json = JSON.stringify(value);
|
|
1221
|
+
} catch {
|
|
1222
|
+
json = 'null';
|
|
1223
|
+
}
|
|
1224
|
+
if (typeof json !== 'string') json = 'null';
|
|
1225
|
+
return json
|
|
1226
|
+
.replace(/</g, '\\u003c')
|
|
1227
|
+
.replace(/\u2028/g, '\\u2028')
|
|
1228
|
+
.replace(/\u2029/g, '\\u2029');
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Browser bootstrap read by client.js before it registers slots. Only the
|
|
1233
|
+
* non-sensitive panel switch is exposed.
|
|
1234
|
+
* @param {boolean} panel
|
|
1235
|
+
* @returns {string}
|
|
1236
|
+
*/
|
|
1237
|
+
export function buildPanelGlobalScript(panel) {
|
|
1238
|
+
return `<script>window.${PANEL_GLOBAL}=${scriptJson({ panel: panel !== false })};</script>`;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Insert the panel bootstrap into an index.html document. DSH taps receive the
|
|
1243
|
+
* raw index on every response, so insertion is idempotent and non-destructive.
|
|
1244
|
+
* @param {string} html
|
|
1245
|
+
* @param {boolean} panel
|
|
1246
|
+
* @returns {string}
|
|
1247
|
+
*/
|
|
1248
|
+
export function injectPanelGlobal(html, panel) {
|
|
1249
|
+
if (typeof html !== 'string' || html.length === 0) return html;
|
|
1250
|
+
if (html.includes(`window.${PANEL_GLOBAL}=`)) return html;
|
|
1251
|
+
const script = buildPanelGlobalScript(panel);
|
|
1252
|
+
const headEnd = html.indexOf('</head>');
|
|
1253
|
+
if (headEnd !== -1) return `${html.slice(0, headEnd)}${script}${html.slice(headEnd)}`;
|
|
1254
|
+
const bodyEnd = html.indexOf('</body>');
|
|
1255
|
+
if (bodyEnd !== -1) return `${html.slice(0, bodyEnd)}${script}${html.slice(bodyEnd)}`;
|
|
1256
|
+
return `${html}${script}`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Mount the official DSH MCP client against the discovered daemon.
|
|
1261
|
+
*
|
|
1262
|
+
* The module is imported lazily so this package stays installable without the
|
|
1263
|
+
* DSH peer: Jest and plain Node simply fail the import, which is logged with a
|
|
1264
|
+
* stable code while the status route and panel keep working. Inside DSH the
|
|
1265
|
+
* host resolves the peer from its own installation. The MCP client owns
|
|
1266
|
+
* connection, reconnection, and tool registration; this plugin never speaks
|
|
1267
|
+
* MCP itself and never retries on its own.
|
|
1268
|
+
*
|
|
1269
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
1270
|
+
* @param {string} daemonUrl
|
|
1271
|
+
* @param {{
|
|
1272
|
+
* log?: (level: 'info' | 'warn', message: string) => void,
|
|
1273
|
+
* importModule?: (specifier: string) => Promise<unknown>,
|
|
1274
|
+
* }} [options]
|
|
1275
|
+
* @returns {Promise<boolean>} whether the bridge plugin was mounted
|
|
1276
|
+
*/
|
|
1277
|
+
export async function mountMcp(ctx, daemonUrl, options = {}) {
|
|
1278
|
+
const opts = isObject(options) ? options : {};
|
|
1279
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
1280
|
+
if (ctx === null || typeof ctx !== 'object' || typeof ctx.plugin !== 'function') {
|
|
1281
|
+
log('warn', 'mcp client mount skipped: ctx.plugin is unavailable');
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
const module = typeof opts.importModule === 'function'
|
|
1286
|
+
? await opts.importModule(MCP_CLIENT_PACKAGE)
|
|
1287
|
+
: await import('@deepseek-ai/dsh-mcp-client');
|
|
1288
|
+
ctx.plugin(module, {
|
|
1289
|
+
serverName: 'mebular',
|
|
1290
|
+
transport: 'streamable-http',
|
|
1291
|
+
url: joinUrl(daemonUrl, '/mcp'),
|
|
1292
|
+
failOnStartupError: false,
|
|
1293
|
+
});
|
|
1294
|
+
log('info', 'mcp client mounted');
|
|
1295
|
+
return true;
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
// Outside a DSH host the peer is absent (ERR_MODULE_NOT_FOUND). That must
|
|
1298
|
+
// not fail plugin activation: the panel and /mebular/embed-info stay up.
|
|
1299
|
+
log('warn', `mcp client mount failed (${safeErrorCode(error)})`);
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Mount the host plugin. Attach is the default and never starts a process;
|
|
1306
|
+
* managed starts at most one child and stops it on dispose. The route is
|
|
1307
|
+
* optional because profiles without a webServer (headless/TUI) never mount it.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
1310
|
+
* @param {unknown} [config]
|
|
1311
|
+
* @param {{
|
|
1312
|
+
* env?: Record<string, string | undefined>,
|
|
1313
|
+
* home?: string,
|
|
1314
|
+
* runtime?: unknown,
|
|
1315
|
+
* pidAlive?: (pid: number) => boolean,
|
|
1316
|
+
* importModule?: (specifier: string) => Promise<unknown>,
|
|
1317
|
+
* }} [options] Test seams; DSH passes only (ctx, config).
|
|
1318
|
+
*/
|
|
1319
|
+
export function apply(ctx, config = {}, options = {}) {
|
|
1320
|
+
if (ctx === null || typeof ctx !== 'object') return;
|
|
1321
|
+
const raw = isObject(config) ? config : {};
|
|
1322
|
+
const opts = isObject(options) ? options : {};
|
|
1323
|
+
const rawLogger = ctx.logger;
|
|
1324
|
+
const log = (level, message) => {
|
|
1325
|
+
try {
|
|
1326
|
+
const fn = rawLogger?.[level];
|
|
1327
|
+
if (typeof fn === 'function') fn.call(rawLogger, `@mebular/dsh-plugin: ${message}`);
|
|
1328
|
+
else if (level !== 'info' && typeof rawLogger?.warn === 'function') {
|
|
1329
|
+
rawLogger.warn(`@mebular/dsh-plugin: ${message}`);
|
|
1330
|
+
}
|
|
1331
|
+
} catch {
|
|
1332
|
+
// Logging must never fail plugin activation.
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
const configOptions = { warn: (message) => log('warn', message) };
|
|
1337
|
+
for (const key of ['env', 'home', 'runtime', 'pidAlive']) {
|
|
1338
|
+
if (opts[key] !== undefined) configOptions[key] = opts[key];
|
|
1339
|
+
}
|
|
1340
|
+
const resolved = resolveConfig(raw, configOptions);
|
|
1341
|
+
const lifecycle = createDaemonLifecycle({
|
|
1342
|
+
config: resolved,
|
|
1343
|
+
logger: {
|
|
1344
|
+
info: (message) => log('info', message),
|
|
1345
|
+
warn: (message) => log('warn', message),
|
|
1346
|
+
},
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
if (typeof ctx.effect === 'function') {
|
|
1350
|
+
ctx.effect(
|
|
1351
|
+
() => () => {
|
|
1352
|
+
lifecycle.dispose();
|
|
1353
|
+
},
|
|
1354
|
+
'@mebular/dsh-plugin: daemon lifecycle',
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
void lifecycle.start();
|
|
1358
|
+
|
|
1359
|
+
// Mount the official bridge on the same discovered origin as the panel. A
|
|
1360
|
+
// failure is logged, never fatal; the official client reconnects on its own
|
|
1361
|
+
// when the daemon is down at activation time.
|
|
1362
|
+
void mountMcp(ctx, resolved.daemonUrl, {
|
|
1363
|
+
log: (level, message) => log(level, message),
|
|
1364
|
+
...(typeof opts.importModule === 'function' ? { importModule: opts.importModule } : {}),
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
const handler = createEmbedInfoHandler({ config: resolved, lifecycle });
|
|
1368
|
+
if (typeof ctx.inject !== 'function') return;
|
|
1369
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
1370
|
+
const webServer = webCtx?.webServer;
|
|
1371
|
+
if (webServer === undefined || webServer === null) return;
|
|
1372
|
+
if (typeof webServer.register === 'function') {
|
|
1373
|
+
webCtx.effect(
|
|
1374
|
+
() => webServer.register({
|
|
1375
|
+
kind: 'exact',
|
|
1376
|
+
path: EMBED_INFO_PATH,
|
|
1377
|
+
handler,
|
|
1378
|
+
}),
|
|
1379
|
+
'@mebular/dsh-plugin: GET /mebular/embed-info',
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
// DSH 0.2.1-rc.2 exposes WebServer.tapIndex(transform) (see
|
|
1383
|
+
// @deepseek-ai/dsh-host-webserver). The global makes panel:false effective
|
|
1384
|
+
// before client apply runs. Without a webServer/tapIndex, client defaults
|
|
1385
|
+
// to panel:true and embed-info still reports the configured value.
|
|
1386
|
+
if (typeof webServer.tapIndex === 'function') {
|
|
1387
|
+
webCtx.effect(
|
|
1388
|
+
() => webServer.tapIndex((html) => injectPanelGlobal(html, resolved.panel)),
|
|
1389
|
+
'@mebular/dsh-plugin: panel bootstrap',
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
}
|