@zhengjunyao/dsh-restart 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/README.zh.md +163 -0
- package/cordis.patch.yml +17 -0
- package/helper/restart-helper.mjs +828 -0
- package/lib/client.js +1706 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1401 -0
- package/lib/types/client/RestartPanel.d.ts +5 -0
- package/lib/types/client/api.d.ts +230 -0
- package/lib/types/client/floating.d.ts +2 -0
- package/lib/types/client/index.d.ts +8 -0
- package/lib/types/client/overlay.d.ts +2 -0
- package/lib/types/client/state.d.ts +79 -0
- package/lib/types/config.d.ts +125 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/launchd.d.ts +80 -0
- package/lib/types/restart.d.ts +236 -0
- package/lib/types/routes.d.ts +66 -0
- package/lib/types/tools.d.ts +25 -0
- package/package.json +96 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
import { access, chmod, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
/**
|
|
11
|
+
* dsh-restart — plugin config, on-disk layout, and restart history.
|
|
12
|
+
*
|
|
13
|
+
* Everything this plugin owns lives under one directory
|
|
14
|
+
* (`~/.dsh/dsh-restart` by default, override with `DSH_RESTART_HOME`):
|
|
15
|
+
*
|
|
16
|
+
* config.json plugin settings (0600)
|
|
17
|
+
* history.json one record per requested restart (newest first)
|
|
18
|
+
* status.json written by the detached helper — live restart state
|
|
19
|
+
* pending-spec.json handoff payload for the next helper run
|
|
20
|
+
* logs/<stamp>.log stdout+stderr of one restarted host
|
|
21
|
+
*/
|
|
22
|
+
/** Shipped defaults. */
|
|
23
|
+
const DEFAULT_CONFIG = {
|
|
24
|
+
enabled: true,
|
|
25
|
+
announceToAgent: true,
|
|
26
|
+
entry: "sidebar",
|
|
27
|
+
restartMode: "auto",
|
|
28
|
+
fallbackPort: 3099,
|
|
29
|
+
bootTimeoutMs: 12e4,
|
|
30
|
+
maxAttempts: 2,
|
|
31
|
+
killGraceMs: 6e3,
|
|
32
|
+
portFreeTimeoutMs: 25e3,
|
|
33
|
+
lingerMs: 4e3,
|
|
34
|
+
logLines: 200,
|
|
35
|
+
autoReload: true,
|
|
36
|
+
showOverlay: true,
|
|
37
|
+
probeIntervalMs: 1200,
|
|
38
|
+
historyLimit: 30
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Plugin directory.
|
|
42
|
+
*
|
|
43
|
+
* Resolution order mirrors DSH's own: an explicit `DSH_RESTART_HOME` (used by
|
|
44
|
+
* the tests and by anyone running a throwaway instance), then DSH's own
|
|
45
|
+
* `DSH_HOME` when it is set — a launcher or a rescue capsule may relocate the
|
|
46
|
+
* whole home — and only then the conventional `~/.dsh`. Hardcoding `~/.dsh`
|
|
47
|
+
* would silently write a second, wrong home on such setups.
|
|
48
|
+
*/
|
|
49
|
+
function restartHome() {
|
|
50
|
+
const override = process.env.DSH_RESTART_HOME;
|
|
51
|
+
if (typeof override === "string" && override.trim() !== "") return override;
|
|
52
|
+
const dshHome = process.env.DSH_HOME;
|
|
53
|
+
return join(typeof dshHome === "string" && dshHome.trim() !== "" ? dshHome : join(homedir(), ".dsh"), "dsh-restart");
|
|
54
|
+
}
|
|
55
|
+
/** Settings file (override with DSH_RESTART_CONFIG). */
|
|
56
|
+
function configPath() {
|
|
57
|
+
const override = process.env.DSH_RESTART_CONFIG;
|
|
58
|
+
if (typeof override === "string" && override.trim() !== "") return override;
|
|
59
|
+
return join(restartHome(), "config.json");
|
|
60
|
+
}
|
|
61
|
+
/** Restart history file. */
|
|
62
|
+
function historyPath() {
|
|
63
|
+
return join(restartHome(), "history.json");
|
|
64
|
+
}
|
|
65
|
+
/** Live helper status file. */
|
|
66
|
+
function statusPath() {
|
|
67
|
+
return join(restartHome(), "status.json");
|
|
68
|
+
}
|
|
69
|
+
/** Handoff payload for the helper. */
|
|
70
|
+
function specPath() {
|
|
71
|
+
return join(restartHome(), "pending-spec.json");
|
|
72
|
+
}
|
|
73
|
+
/** Directory holding one log file per restarted host. */
|
|
74
|
+
function logsDir() {
|
|
75
|
+
return join(restartHome(), "logs");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The detached helper shipped with this package. Resolved from the module URL
|
|
79
|
+
* so it works both from a local checkout and from an installed copy.
|
|
80
|
+
*/
|
|
81
|
+
function helperPath() {
|
|
82
|
+
return fileURLToPath(new URL("../helper/restart-helper.mjs", import.meta.url));
|
|
83
|
+
}
|
|
84
|
+
/** Coerce an unknown value into a bounded integer. */
|
|
85
|
+
function intIn(value, fallback, min, max) {
|
|
86
|
+
const raw = typeof value === "number" ? value : Number(value);
|
|
87
|
+
if (!Number.isFinite(raw)) return fallback;
|
|
88
|
+
return Math.max(min, Math.min(max, Math.round(raw)));
|
|
89
|
+
}
|
|
90
|
+
/** Normalize a partial config against the defaults (never throws). */
|
|
91
|
+
function normalizeConfig(patch, base = DEFAULT_CONFIG) {
|
|
92
|
+
const source = patch ?? {};
|
|
93
|
+
const entry = source.entry;
|
|
94
|
+
return {
|
|
95
|
+
enabled: typeof source.enabled === "boolean" ? source.enabled : base.enabled,
|
|
96
|
+
announceToAgent: typeof source.announceToAgent === "boolean" ? source.announceToAgent : base.announceToAgent,
|
|
97
|
+
entry: entry === "sidebar" || entry === "ball" || entry === "both" || entry === "off" ? entry : base.entry,
|
|
98
|
+
restartMode: source.restartMode === "helper" || source.restartMode === "launchd" || source.restartMode === "auto" ? source.restartMode : base.restartMode,
|
|
99
|
+
fallbackPort: intIn(source.fallbackPort ?? base.fallbackPort, base.fallbackPort, 1, 65535),
|
|
100
|
+
bootTimeoutMs: intIn(source.bootTimeoutMs ?? base.bootTimeoutMs, base.bootTimeoutMs, 5e3, 9e5),
|
|
101
|
+
maxAttempts: intIn(source.maxAttempts ?? base.maxAttempts, base.maxAttempts, 1, 5),
|
|
102
|
+
killGraceMs: intIn(source.killGraceMs ?? base.killGraceMs, base.killGraceMs, 0, 12e4),
|
|
103
|
+
portFreeTimeoutMs: intIn(source.portFreeTimeoutMs ?? base.portFreeTimeoutMs, base.portFreeTimeoutMs, 0, 3e5),
|
|
104
|
+
lingerMs: intIn(source.lingerMs ?? base.lingerMs, base.lingerMs, 0, 6e5),
|
|
105
|
+
logLines: intIn(source.logLines ?? base.logLines, base.logLines, 20, 2e3),
|
|
106
|
+
autoReload: typeof source.autoReload === "boolean" ? source.autoReload : base.autoReload,
|
|
107
|
+
showOverlay: typeof source.showOverlay === "boolean" ? source.showOverlay : base.showOverlay,
|
|
108
|
+
probeIntervalMs: intIn(source.probeIntervalMs ?? base.probeIntervalMs, base.probeIntervalMs, 300, 3e4),
|
|
109
|
+
historyLimit: intIn(source.historyLimit ?? base.historyLimit, base.historyLimit, 1, 500)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Read a JSON file, returning null when it is missing or unparsable. */
|
|
113
|
+
async function readJson(file) {
|
|
114
|
+
try {
|
|
115
|
+
const text = await readFile(file, "utf8");
|
|
116
|
+
const parsed = JSON.parse(text);
|
|
117
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Write JSON atomically with mode 0600. */
|
|
123
|
+
async function writeJson$1(file, value) {
|
|
124
|
+
await mkdir(dirname(file), { recursive: true });
|
|
125
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
126
|
+
await writeFile(tmp, JSON.stringify(value, null, 2), { mode: 384 });
|
|
127
|
+
await rename(tmp, file);
|
|
128
|
+
await chmod(file, 384).catch(() => void 0);
|
|
129
|
+
}
|
|
130
|
+
/** The effective config (defaults + file), plus where it came from. */
|
|
131
|
+
async function loadConfig() {
|
|
132
|
+
const file = configPath();
|
|
133
|
+
const stored = await readJson(file);
|
|
134
|
+
if (stored === null) return {
|
|
135
|
+
config: normalizeConfig(void 0),
|
|
136
|
+
exists: false,
|
|
137
|
+
file
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
config: normalizeConfig(stored),
|
|
141
|
+
exists: true,
|
|
142
|
+
file
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Merge a patch into the stored config and return the fresh value. */
|
|
146
|
+
async function saveConfig(patch) {
|
|
147
|
+
const { config } = await loadConfig();
|
|
148
|
+
const next = normalizeConfig(patch, config);
|
|
149
|
+
await writeJson$1(configPath(), next);
|
|
150
|
+
return next;
|
|
151
|
+
}
|
|
152
|
+
/** Delete the stored config (back to shipped defaults). */
|
|
153
|
+
async function resetConfig() {
|
|
154
|
+
await writeJson$1(configPath(), DEFAULT_CONFIG);
|
|
155
|
+
return { ...DEFAULT_CONFIG };
|
|
156
|
+
}
|
|
157
|
+
/** Append one restart record (newest first, bounded by historyLimit). */
|
|
158
|
+
async function appendHistory(record, limit) {
|
|
159
|
+
const existing = await readJson(historyPath()) ?? [];
|
|
160
|
+
const list = Array.isArray(existing) ? existing : [];
|
|
161
|
+
list.unshift(record);
|
|
162
|
+
await writeJson$1(historyPath(), list.slice(0, Math.max(1, limit)));
|
|
163
|
+
}
|
|
164
|
+
/** Read the restart history (newest first). */
|
|
165
|
+
async function readHistory(limit = 20) {
|
|
166
|
+
const existing = await readJson(historyPath());
|
|
167
|
+
return (Array.isArray(existing) ? existing : []).slice(0, Math.max(1, limit));
|
|
168
|
+
}
|
|
169
|
+
/** Ensure the plugin directories exist (0600 where it matters). */
|
|
170
|
+
async function ensureLayout() {
|
|
171
|
+
await mkdir(logsDir(), { recursive: true });
|
|
172
|
+
await chmod(restartHome(), 448).catch(() => void 0);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Synchronous config read, for the plugin's apply() path.
|
|
176
|
+
*
|
|
177
|
+
* Mounting must stay synchronous: cordis effects have to be created inside the
|
|
178
|
+
* plugin's own apply scope, so the roster cannot wait on a promise.
|
|
179
|
+
*/
|
|
180
|
+
function loadConfigSync() {
|
|
181
|
+
const file = configPath();
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
184
|
+
if (typeof parsed === "object" && parsed !== null) return {
|
|
185
|
+
config: normalizeConfig(parsed),
|
|
186
|
+
exists: true,
|
|
187
|
+
file
|
|
188
|
+
};
|
|
189
|
+
} catch {}
|
|
190
|
+
return {
|
|
191
|
+
config: normalizeConfig(void 0),
|
|
192
|
+
exists: false,
|
|
193
|
+
file
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Materialize the config file on first run so the settings become discoverable
|
|
198
|
+
* and editable; the composition row only seeds it, the file is authoritative.
|
|
199
|
+
* @param seed - values from the plugin row (enabled / announceToAgent / …).
|
|
200
|
+
*/
|
|
201
|
+
function seedConfigSync(seed) {
|
|
202
|
+
const file = configPath();
|
|
203
|
+
if (existsSync(file)) return false;
|
|
204
|
+
try {
|
|
205
|
+
mkdirSync(restartHome(), {
|
|
206
|
+
recursive: true,
|
|
207
|
+
mode: 448
|
|
208
|
+
});
|
|
209
|
+
writeFileSync(file, JSON.stringify(normalizeConfig(seed), null, 2), { mode: 384 });
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/launchd.ts
|
|
217
|
+
/**
|
|
218
|
+
* dsh-restart — macOS launchd awareness.
|
|
219
|
+
*
|
|
220
|
+
* A DSH host is often managed by a launchd job (`com.dsh.web` with
|
|
221
|
+
* `KeepAlive: true` on this machine). That changes the correct restart from
|
|
222
|
+
* "relaunch it ourselves" to "ask launchd to restart it": a helper that spawns
|
|
223
|
+
* its own child would race the job for the listening port, and whichever loses
|
|
224
|
+
* dies with `EADDRINUSE`.
|
|
225
|
+
*
|
|
226
|
+
* So the plugin detects the situation and switches strategy:
|
|
227
|
+
*
|
|
228
|
+
* launchd-managed → `launchctl kickstart -k gui/<uid>/<label>` (the job comes
|
|
229
|
+
* back with the plist's own cwd/env/argv) + the helper runs
|
|
230
|
+
* in observe mode, following the plist's stdout/stderr.
|
|
231
|
+
* anything else → the helper relaunches the exact same command itself.
|
|
232
|
+
*
|
|
233
|
+
* Detection is a pure read of `XPC_SERVICE_NAME` (set by launchd on every
|
|
234
|
+
* process it starts) confirmed by `launchctl print`, and every failure mode
|
|
235
|
+
* falls back to the self-relaunch path — a host that is not launchd-managed must
|
|
236
|
+
* never end up depending on launchctl.
|
|
237
|
+
*/
|
|
238
|
+
const run = promisify(execFile);
|
|
239
|
+
/** Run a command, resolving to { ok, stdout } instead of throwing. */
|
|
240
|
+
async function tryRun(file, args, timeoutMs = 4e3) {
|
|
241
|
+
try {
|
|
242
|
+
const { stdout, stderr } = await run(file, [...args], {
|
|
243
|
+
timeout: timeoutMs,
|
|
244
|
+
encoding: "utf8"
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
stdout: stdout ?? "",
|
|
249
|
+
stderr: stderr ?? ""
|
|
250
|
+
};
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const failure = error;
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
stdout: failure.stdout ?? "",
|
|
256
|
+
stderr: failure.stderr ?? failure.message ?? ""
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Read one raw key out of a plist (empty string when absent/unparsable). */
|
|
261
|
+
async function plistValue(plistPath, key) {
|
|
262
|
+
const result = await tryRun("/usr/bin/plutil", [
|
|
263
|
+
"-extract",
|
|
264
|
+
key,
|
|
265
|
+
"raw",
|
|
266
|
+
"-o",
|
|
267
|
+
"-",
|
|
268
|
+
plistPath
|
|
269
|
+
], 3e3);
|
|
270
|
+
return result.ok ? result.stdout.trim() : "";
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Which launchd job owns a pid.
|
|
274
|
+
*
|
|
275
|
+
* `launchctl list` prints `PID Status Label`, so matching our own pid is the
|
|
276
|
+
* authoritative answer — and the only reliable one: **Node rewrites
|
|
277
|
+
* `XPC_SERVICE_NAME` to `0` in `process.env`**, so the environment variable
|
|
278
|
+
* launchd set (still visible in the kernel environment, e.g. `ps eww -p <pid>`)
|
|
279
|
+
* cannot be read from inside the process. The env var is kept only as a
|
|
280
|
+
* secondary signal for the case where `launchctl list` is unavailable.
|
|
281
|
+
*
|
|
282
|
+
* @param pid - the process to look up.
|
|
283
|
+
* @returns the job label, or null when the process is not a launchd job.
|
|
284
|
+
*/
|
|
285
|
+
async function labelForPid(pid) {
|
|
286
|
+
const listed = await tryRun("/bin/launchctl", ["list"], 5e3);
|
|
287
|
+
if (listed.ok) for (const line of listed.stdout.split("\n")) {
|
|
288
|
+
const fields = line.trim().split(/\s+/);
|
|
289
|
+
if (fields.length < 3) continue;
|
|
290
|
+
if (fields[0] !== String(pid)) continue;
|
|
291
|
+
const candidate = fields[2];
|
|
292
|
+
if (candidate !== void 0 && candidate !== "" && candidate !== "-") return candidate;
|
|
293
|
+
}
|
|
294
|
+
const fromEnv = (process.env.XPC_SERVICE_NAME ?? "").trim();
|
|
295
|
+
if (fromEnv !== "" && fromEnv !== "0" && fromEnv !== "-" && !fromEnv.includes("/")) return fromEnv;
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Detect a managing launchd job for this process.
|
|
300
|
+
*
|
|
301
|
+
* Returns null on non-macOS, when the process is not launchd-managed, or when
|
|
302
|
+
* `launchctl print` cannot see the job — every one of those means the caller
|
|
303
|
+
* must fall back to the self-relaunch strategy.
|
|
304
|
+
*/
|
|
305
|
+
async function detectLaunchd() {
|
|
306
|
+
return detectLaunchdFor(process.pid);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Detect the launchd job owning an arbitrary pid.
|
|
310
|
+
*
|
|
311
|
+
* The plugin calls it for its own pid (the host is the job); tooling outside the
|
|
312
|
+
* host — a one-off handoff script, a test — needs it for a *different* pid, and
|
|
313
|
+
* in that case `process.env.XPC_SERVICE_NAME` says nothing useful.
|
|
314
|
+
*
|
|
315
|
+
* @param pid - the process to look up.
|
|
316
|
+
*/
|
|
317
|
+
async function detectLaunchdFor(pid) {
|
|
318
|
+
if (process.platform !== "darwin") return null;
|
|
319
|
+
const label = await labelForPid(pid);
|
|
320
|
+
if (label === null) return null;
|
|
321
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
322
|
+
const domain = `gui/${uid}`;
|
|
323
|
+
const printed = await tryRun("/bin/launchctl", ["print", `${domain}/${label}`], 4e3);
|
|
324
|
+
if (!printed.ok) return null;
|
|
325
|
+
const pidMatch = /^\s*pid = (\d+)\s*$/m.exec(printed.stdout);
|
|
326
|
+
const stateMatch = /^\s*state = (\S+)\s*$/m.exec(printed.stdout);
|
|
327
|
+
const plistPath = process.env.DSH_RESTART_PLIST ?? join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
328
|
+
const [stdoutPath, stderrPath] = await Promise.all([plistValue(plistPath, "StandardOutPath"), plistValue(plistPath, "StandardErrorPath")]);
|
|
329
|
+
return {
|
|
330
|
+
label,
|
|
331
|
+
uid,
|
|
332
|
+
domain,
|
|
333
|
+
plistPath,
|
|
334
|
+
stdoutPath,
|
|
335
|
+
stderrPath,
|
|
336
|
+
state: stateMatch?.[1] ?? "unknown",
|
|
337
|
+
pid: pidMatch === null ? null : Number(pidMatch[1])
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Ask launchd to restart the job (`kickstart -k`).
|
|
342
|
+
*
|
|
343
|
+
* @param info - the detected job.
|
|
344
|
+
* @returns ok plus launchctl's own output when it refused.
|
|
345
|
+
*/
|
|
346
|
+
async function kickstart(info) {
|
|
347
|
+
const result = await tryRun("/bin/launchctl", [
|
|
348
|
+
"kickstart",
|
|
349
|
+
"-k",
|
|
350
|
+
`${info.domain}/${info.label}`
|
|
351
|
+
], 1e4);
|
|
352
|
+
if (result.ok) return {
|
|
353
|
+
ok: true,
|
|
354
|
+
error: ""
|
|
355
|
+
};
|
|
356
|
+
const message = (result.stderr || result.stdout).trim();
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: `launchctl kickstart 失败:${message === "" ? "未知错误" : message}`
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** The command a retry should re-run for a managed host. */
|
|
363
|
+
function kickCommand(info) {
|
|
364
|
+
return [
|
|
365
|
+
"/bin/launchctl",
|
|
366
|
+
"kickstart",
|
|
367
|
+
"-k",
|
|
368
|
+
`${info.domain}/${info.label}`
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
/** Read the tail of a launchd log file ('' when missing). */
|
|
372
|
+
async function readLaunchdLog(file, lines) {
|
|
373
|
+
if (file === "") return "";
|
|
374
|
+
try {
|
|
375
|
+
return (await readFile(file, "utf8")).split(/\r?\n/).filter((line) => line !== "").slice(-Math.max(1, lines)).join("\n");
|
|
376
|
+
} catch {
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/restart.ts
|
|
382
|
+
/**
|
|
383
|
+
* dsh-restart — the restart engine (host half).
|
|
384
|
+
*
|
|
385
|
+
* A host cannot restart itself in place: the moment it exits, the browser has
|
|
386
|
+
* nothing to talk to, and any failure would be invisible. So the handoff goes
|
|
387
|
+
* through a detached helper process that survives the host:
|
|
388
|
+
*
|
|
389
|
+
* panel / tool ──POST /api/dsh-restart/restart──▶ host
|
|
390
|
+
* host ──writes pending-spec.json, spawns helper──▶ helper (detached)
|
|
391
|
+
* host ──SIGTERM after a short delay──▶ exit
|
|
392
|
+
* helper ──waits for the port to free, relaunches the same command──▶ new host
|
|
393
|
+
* helper ──status.json + fallback console──▶ the page shows progress / errors
|
|
394
|
+
*
|
|
395
|
+
* The relaunch reuses the exact invocation the running host was started with
|
|
396
|
+
* (`process.execArgv` + `argv[1:]`), so `dsh web --port 3080`, a `node` path
|
|
397
|
+
* override, a custom port and a custom cwd all survive a restart unchanged.
|
|
398
|
+
*/
|
|
399
|
+
/**
|
|
400
|
+
* Lines that usually carry the reason a boot failed. The stack-frame branch
|
|
401
|
+
* insists on a real file-ish frame so timestamps ("… at 2026-09-12T02:08:18Z")
|
|
402
|
+
* are not mistaken for errors.
|
|
403
|
+
*/
|
|
404
|
+
const ERROR_HINT = new RegExp([
|
|
405
|
+
"\\bError\\b",
|
|
406
|
+
"\\bERROR\\b",
|
|
407
|
+
"error:",
|
|
408
|
+
"EADDRINUSE",
|
|
409
|
+
"ECONNREFUSED",
|
|
410
|
+
"ENOENT",
|
|
411
|
+
"EACCES",
|
|
412
|
+
"MODULE_NOT_FOUND",
|
|
413
|
+
"Cannot find (module|package)",
|
|
414
|
+
"UnhandledPromiseRejection",
|
|
415
|
+
"uncaughtException",
|
|
416
|
+
"FATAL",
|
|
417
|
+
"fatal:",
|
|
418
|
+
"SyntaxError",
|
|
419
|
+
"TypeError",
|
|
420
|
+
"ReferenceError",
|
|
421
|
+
"is not a function",
|
|
422
|
+
"failed to load",
|
|
423
|
+
"加载失败",
|
|
424
|
+
"启动失败",
|
|
425
|
+
"\\bat\\s+.*(?:\\.(?:js|mjs|cjs|ts|tsx|jsx|json)|node:[\\w/]+):\\d+:\\d+"
|
|
426
|
+
].join("|"));
|
|
427
|
+
/** True when a pid is alive (signal 0 probe). */
|
|
428
|
+
function isAlive(pid) {
|
|
429
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
430
|
+
try {
|
|
431
|
+
process.kill(pid, 0);
|
|
432
|
+
return true;
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** Cached launchd detection (the check shells out, so do not repeat it per poll). */
|
|
438
|
+
let launchdCache = null;
|
|
439
|
+
const LAUNCHD_TTL_MS = 3e4;
|
|
440
|
+
/** Detect (and cache) the launchd job managing this host. */
|
|
441
|
+
async function launchdInfo(force = false) {
|
|
442
|
+
if (!force && launchdCache !== null && Date.now() - launchdCache.at < LAUNCHD_TTL_MS) return launchdCache.info;
|
|
443
|
+
const info = await detectLaunchd().catch(() => null);
|
|
444
|
+
launchdCache = {
|
|
445
|
+
at: Date.now(),
|
|
446
|
+
info
|
|
447
|
+
};
|
|
448
|
+
return info;
|
|
449
|
+
}
|
|
450
|
+
/** Read the DSH version from the package that owns the running entry script. */
|
|
451
|
+
async function readDshVersion() {
|
|
452
|
+
const entry = process.argv[1];
|
|
453
|
+
if (typeof entry !== "string" || entry === "") return "";
|
|
454
|
+
let resolved = entry;
|
|
455
|
+
try {
|
|
456
|
+
resolved = realpathSync(entry);
|
|
457
|
+
} catch {
|
|
458
|
+
resolved = entry;
|
|
459
|
+
}
|
|
460
|
+
let dir = dirname(resolved);
|
|
461
|
+
for (let i = 0; i < 3; i++) {
|
|
462
|
+
try {
|
|
463
|
+
const text = await readFile(join(dir, "package.json"), "utf8");
|
|
464
|
+
const parsed = JSON.parse(text);
|
|
465
|
+
const name = parsed.name;
|
|
466
|
+
const version = parsed.version;
|
|
467
|
+
if (typeof version === "string" && typeof name === "string" && name.includes("dsh")) return version;
|
|
468
|
+
if (typeof version === "string" && i === 1) return version;
|
|
469
|
+
} catch {}
|
|
470
|
+
dir = dirname(dir);
|
|
471
|
+
}
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
/** Best-effort profile name for diagnostics. */
|
|
475
|
+
function readProfile() {
|
|
476
|
+
for (const key of [
|
|
477
|
+
"DSH_PROFILE",
|
|
478
|
+
"DSH_ACTIVE_PROFILE",
|
|
479
|
+
"DSH_PROFILE_NAME"
|
|
480
|
+
]) {
|
|
481
|
+
const value = process.env[key];
|
|
482
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
483
|
+
}
|
|
484
|
+
const args = process.argv.slice(2);
|
|
485
|
+
const index = args.findIndex((token) => token === "--profile" || token === "-P");
|
|
486
|
+
if (index >= 0 && typeof args[index + 1] === "string") return args[index + 1] ?? "";
|
|
487
|
+
return "";
|
|
488
|
+
}
|
|
489
|
+
/** The relaunch command, exactly as this host was started. */
|
|
490
|
+
function launchSignature() {
|
|
491
|
+
const entry = process.argv[1] ?? "";
|
|
492
|
+
return {
|
|
493
|
+
file: process.argv[0] ?? process.execPath,
|
|
494
|
+
args: [
|
|
495
|
+
...process.execArgv,
|
|
496
|
+
entry,
|
|
497
|
+
...process.argv.slice(2)
|
|
498
|
+
],
|
|
499
|
+
cwd: process.cwd()
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Describe the running host. */
|
|
503
|
+
async function hostInfo(options) {
|
|
504
|
+
const signature = launchSignature();
|
|
505
|
+
const helper = helperPath();
|
|
506
|
+
let helperExists = false;
|
|
507
|
+
try {
|
|
508
|
+
await access(helper, constants.R_OK);
|
|
509
|
+
helperExists = true;
|
|
510
|
+
} catch {
|
|
511
|
+
helperExists = false;
|
|
512
|
+
}
|
|
513
|
+
const helperPid = Number(process.env.DSH_RESTART_HELPER_PID ?? "");
|
|
514
|
+
const { config } = await loadConfig();
|
|
515
|
+
const job = config.restartMode === "helper" ? null : await launchdInfo();
|
|
516
|
+
const forced = config.restartMode === "launchd";
|
|
517
|
+
return {
|
|
518
|
+
pid: process.pid,
|
|
519
|
+
ppid: process.ppid,
|
|
520
|
+
startedAt: (/* @__PURE__ */ new Date(Date.now() - process.uptime() * 1e3)).toISOString(),
|
|
521
|
+
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
522
|
+
port: options.port,
|
|
523
|
+
host: options.host,
|
|
524
|
+
url: options.url,
|
|
525
|
+
cwd: signature.cwd,
|
|
526
|
+
nodeVersion: process.version,
|
|
527
|
+
dshVersion: await readDshVersion(),
|
|
528
|
+
profile: readProfile(),
|
|
529
|
+
command: [signature.file, ...signature.args].join(" "),
|
|
530
|
+
restarted: Number.isInteger(helperPid) && helperPid > 0,
|
|
531
|
+
platform: process.platform,
|
|
532
|
+
logsDir: logsDir(),
|
|
533
|
+
statusFile: statusPath(),
|
|
534
|
+
helperFile: helper,
|
|
535
|
+
helperExists,
|
|
536
|
+
launchd: {
|
|
537
|
+
managed: job !== null,
|
|
538
|
+
label: job?.label ?? "",
|
|
539
|
+
state: job?.state ?? "",
|
|
540
|
+
pid: job?.pid ?? null,
|
|
541
|
+
plistPath: job?.plistPath ?? "",
|
|
542
|
+
logFile: job?.stderrPath !== void 0 && job.stderrPath !== "" ? job.stderrPath : job?.stdoutPath ?? "",
|
|
543
|
+
strategy: job !== null || forced ? job === null ? "helper" : "launchd" : "helper"
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/** Timestamp used in log file names (filesystem-safe, local time). */
|
|
548
|
+
function stamp() {
|
|
549
|
+
const now = /* @__PURE__ */ new Date();
|
|
550
|
+
const pad = (value, width = 2) => String(value).padStart(width, "0");
|
|
551
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
552
|
+
}
|
|
553
|
+
/** Build the helper spec for a restart of the current host. */
|
|
554
|
+
async function buildSpec(options) {
|
|
555
|
+
const signature = launchSignature();
|
|
556
|
+
const logFile = join(logsDir(), `${stamp()}-${process.pid}.log`);
|
|
557
|
+
const job = options.launchd ?? null;
|
|
558
|
+
return {
|
|
559
|
+
port: options.port,
|
|
560
|
+
host: options.host,
|
|
561
|
+
url: options.url,
|
|
562
|
+
file: signature.file,
|
|
563
|
+
args: signature.args,
|
|
564
|
+
cwd: signature.cwd,
|
|
565
|
+
env: { ...process.env },
|
|
566
|
+
oldPid: process.pid,
|
|
567
|
+
logFile,
|
|
568
|
+
statusFile: statusPath(),
|
|
569
|
+
fallbackPort: options.config.fallbackPort,
|
|
570
|
+
bootTimeoutMs: options.config.bootTimeoutMs,
|
|
571
|
+
maxAttempts: options.config.maxAttempts,
|
|
572
|
+
killGraceMs: options.config.killGraceMs,
|
|
573
|
+
portFreeTimeoutMs: options.config.portFreeTimeoutMs,
|
|
574
|
+
lingerMs: options.config.lingerMs,
|
|
575
|
+
ringLines: options.config.logLines,
|
|
576
|
+
dshVersion: await readDshVersion(),
|
|
577
|
+
profile: readProfile(),
|
|
578
|
+
mode: job === null ? "spawn" : "observe",
|
|
579
|
+
owner: job === null ? "dsh-restart helper" : `launchd ${job.label}`,
|
|
580
|
+
kickCommand: job === null ? [] : kickCommand(job),
|
|
581
|
+
kickDelayMs: 1200,
|
|
582
|
+
observeLog: job === null ? "" : job.stderrPath !== "" ? job.stderrPath : job.stdoutPath,
|
|
583
|
+
failureReport: failureReportPath()
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Hand the restart over to a detached helper.
|
|
588
|
+
*
|
|
589
|
+
* Returns as soon as the helper is running; the caller is responsible for
|
|
590
|
+
* answering the HTTP request first and only then ending this process (see
|
|
591
|
+
* {@link scheduleSelfExit}), so the browser always gets a definite reply.
|
|
592
|
+
*
|
|
593
|
+
* @param options - config, listen address, and who asked.
|
|
594
|
+
*/
|
|
595
|
+
async function requestRestart(options) {
|
|
596
|
+
const { config } = options;
|
|
597
|
+
await ensureLayout();
|
|
598
|
+
const job = config.restartMode === "helper" ? null : await launchdInfo();
|
|
599
|
+
const forcedLaunchd = config.restartMode === "launchd";
|
|
600
|
+
const spec = await buildSpec({
|
|
601
|
+
...options,
|
|
602
|
+
launchd: job
|
|
603
|
+
});
|
|
604
|
+
const mode = job === null ? "helper" : "launchd";
|
|
605
|
+
const specFile = specPath();
|
|
606
|
+
const record = {
|
|
607
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
608
|
+
source: options.source,
|
|
609
|
+
reason: options.reason,
|
|
610
|
+
oldPid: process.pid,
|
|
611
|
+
helperPid: null,
|
|
612
|
+
port: spec.port,
|
|
613
|
+
logFile: spec.logFile,
|
|
614
|
+
statusFile: spec.statusFile,
|
|
615
|
+
outcome: "pending"
|
|
616
|
+
};
|
|
617
|
+
const fail = (error, helperPid = null) => ({
|
|
618
|
+
ok: false,
|
|
619
|
+
error,
|
|
620
|
+
helperPid,
|
|
621
|
+
logFile: spec.logFile,
|
|
622
|
+
statusFile: spec.statusFile,
|
|
623
|
+
specFile,
|
|
624
|
+
fallbackPort: config.fallbackPort,
|
|
625
|
+
fallbackUrl: `http://${spec.host}:${config.fallbackPort}`,
|
|
626
|
+
exitInMs: 0,
|
|
627
|
+
mode,
|
|
628
|
+
record,
|
|
629
|
+
commit: async () => void 0
|
|
630
|
+
});
|
|
631
|
+
if (forcedLaunchd && job === null) return fail("配置 restartMode=launchd,但没有检测到管理本宿主的 launchd 任务(XPC_SERVICE_NAME 未设置或 launchctl print 失败)");
|
|
632
|
+
const helper = helperPath();
|
|
633
|
+
try {
|
|
634
|
+
await access(helper, constants.R_OK);
|
|
635
|
+
} catch {
|
|
636
|
+
return fail(`找不到重启助手脚本:${helper}(包内 helper/ 目录缺失时请重新安装 dsh-restart)`);
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
await mkdir(dirname(specFile), { recursive: true });
|
|
640
|
+
await writeFile(specFile, JSON.stringify(spec, null, 2), { mode: 384 });
|
|
641
|
+
await chmod(specFile, 384).catch(() => void 0);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return fail(`写入交接文件失败:${String(error?.message ?? error)}`);
|
|
644
|
+
}
|
|
645
|
+
let helperPid = null;
|
|
646
|
+
try {
|
|
647
|
+
const child = spawn(process.execPath, [
|
|
648
|
+
helper,
|
|
649
|
+
"--spec",
|
|
650
|
+
specFile
|
|
651
|
+
], {
|
|
652
|
+
detached: true,
|
|
653
|
+
stdio: "ignore",
|
|
654
|
+
cwd: spec.cwd,
|
|
655
|
+
env: { ...process.env }
|
|
656
|
+
});
|
|
657
|
+
child.unref();
|
|
658
|
+
helperPid = child.pid ?? null;
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return fail(`拉起重启助手失败:${String(error?.message ?? error)}`);
|
|
661
|
+
}
|
|
662
|
+
record.helperPid = helperPid;
|
|
663
|
+
await appendHistory(record, config.historyLimit).catch(() => void 0);
|
|
664
|
+
const exitInMs = options.exitDelayMs ?? 700;
|
|
665
|
+
return {
|
|
666
|
+
ok: true,
|
|
667
|
+
error: "",
|
|
668
|
+
helperPid,
|
|
669
|
+
logFile: spec.logFile,
|
|
670
|
+
statusFile: spec.statusFile,
|
|
671
|
+
specFile,
|
|
672
|
+
fallbackPort: config.fallbackPort,
|
|
673
|
+
fallbackUrl: `http://${spec.host}:${config.fallbackPort}`,
|
|
674
|
+
exitInMs,
|
|
675
|
+
mode,
|
|
676
|
+
record,
|
|
677
|
+
commit: async () => {
|
|
678
|
+
if (mode === "launchd") return;
|
|
679
|
+
scheduleSelfExit(exitInMs);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* End this host so the helper can take over.
|
|
685
|
+
*
|
|
686
|
+
* SIGTERM first (lets DSH close sessions and release the port), then a hard
|
|
687
|
+
* exit as a backstop — the helper SIGKILLs anything still holding the port
|
|
688
|
+
* after its own grace period.
|
|
689
|
+
* @param delayMs - how long to wait before signalling (the HTTP reply needs to flush).
|
|
690
|
+
*/
|
|
691
|
+
function scheduleSelfExit(delayMs) {
|
|
692
|
+
setTimeout(() => {
|
|
693
|
+
try {
|
|
694
|
+
process.kill(process.pid, "SIGTERM");
|
|
695
|
+
} catch {}
|
|
696
|
+
setTimeout(() => {
|
|
697
|
+
process.exit(0);
|
|
698
|
+
}, 15e3).unref();
|
|
699
|
+
}, Math.max(0, delayMs)).unref();
|
|
700
|
+
}
|
|
701
|
+
/** Read the live helper status, if any. */
|
|
702
|
+
async function readHelperStatus() {
|
|
703
|
+
const file = statusPath();
|
|
704
|
+
try {
|
|
705
|
+
const [text, info] = await Promise.all([readFile(file, "utf8"), stat(file)]);
|
|
706
|
+
const parsed = JSON.parse(text);
|
|
707
|
+
const helperPid = Number(parsed.helperPid ?? 0);
|
|
708
|
+
const ageMs = Date.now() - info.mtimeMs;
|
|
709
|
+
return {
|
|
710
|
+
status: parsed,
|
|
711
|
+
alive: helperPid > 0 && isAlive(helperPid) && ageMs < 15 * 6e4,
|
|
712
|
+
ageMs
|
|
713
|
+
};
|
|
714
|
+
} catch {
|
|
715
|
+
return {
|
|
716
|
+
status: null,
|
|
717
|
+
alive: false,
|
|
718
|
+
ageMs: null
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/** Tail a log file (missing files come back as an empty tail, not an error). */
|
|
723
|
+
async function tailFile(file, limit) {
|
|
724
|
+
const empty = {
|
|
725
|
+
file,
|
|
726
|
+
exists: false,
|
|
727
|
+
mtime: "",
|
|
728
|
+
text: "",
|
|
729
|
+
lines: [],
|
|
730
|
+
errorLines: []
|
|
731
|
+
};
|
|
732
|
+
try {
|
|
733
|
+
const [text, info] = await Promise.all([readFile(file, "utf8"), stat(file)]);
|
|
734
|
+
const all = text.split(/\r?\n/).filter((line) => line !== "");
|
|
735
|
+
const lines = all.slice(-limit);
|
|
736
|
+
return {
|
|
737
|
+
file,
|
|
738
|
+
exists: true,
|
|
739
|
+
mtime: new Date(info.mtimeMs).toISOString(),
|
|
740
|
+
text: lines.join("\n"),
|
|
741
|
+
lines,
|
|
742
|
+
errorLines: all.filter((line) => ERROR_HINT.test(line)).slice(-40)
|
|
743
|
+
};
|
|
744
|
+
} catch {
|
|
745
|
+
return empty;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/** Where the helper drops the copy-ready failure report. */
|
|
749
|
+
function failureReportPath() {
|
|
750
|
+
return join(restartHome(), "last-failure.md");
|
|
751
|
+
}
|
|
752
|
+
/** The newest log file in the logs directory (null when none exist). */
|
|
753
|
+
async function newestLogFile() {
|
|
754
|
+
try {
|
|
755
|
+
const logs = (await readdir(logsDir())).filter((name) => name.endsWith(".log"));
|
|
756
|
+
if (logs.length === 0) return null;
|
|
757
|
+
const stats = await Promise.all(logs.map(async (name) => ({
|
|
758
|
+
name,
|
|
759
|
+
mtime: (await stat(join(logsDir(), name))).mtimeMs
|
|
760
|
+
})));
|
|
761
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
762
|
+
const newest = stats[0];
|
|
763
|
+
return newest === void 0 ? null : join(logsDir(), newest.name);
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** List recent log files (newest first). */
|
|
769
|
+
async function listLogs(limit = 20) {
|
|
770
|
+
try {
|
|
771
|
+
const logs = (await readdir(logsDir())).filter((name) => name.endsWith(".log"));
|
|
772
|
+
const rows = await Promise.all(logs.map(async (name) => {
|
|
773
|
+
const info = await stat(join(logsDir(), name));
|
|
774
|
+
return {
|
|
775
|
+
name,
|
|
776
|
+
file: join(logsDir(), name),
|
|
777
|
+
size: info.size,
|
|
778
|
+
mtime: new Date(info.mtimeMs).toISOString()
|
|
779
|
+
};
|
|
780
|
+
}));
|
|
781
|
+
rows.sort((a, b) => a.mtime < b.mtime ? 1 : -1);
|
|
782
|
+
return rows.slice(0, Math.max(1, limit));
|
|
783
|
+
} catch {
|
|
784
|
+
return [];
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/routes.ts
|
|
789
|
+
/** Route paths. */
|
|
790
|
+
const RESTART_API = {
|
|
791
|
+
status: "/api/dsh-restart/status",
|
|
792
|
+
probe: "/api/dsh-restart/probe",
|
|
793
|
+
restart: "/api/dsh-restart/restart",
|
|
794
|
+
logs: "/api/dsh-restart/logs",
|
|
795
|
+
history: "/api/dsh-restart/history",
|
|
796
|
+
config: "/api/dsh-restart/config",
|
|
797
|
+
helper: "/api/dsh-restart/helper",
|
|
798
|
+
helperRetry: "/api/dsh-restart/helper/retry"
|
|
799
|
+
};
|
|
800
|
+
/** Cap on JSON request bodies. */
|
|
801
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
802
|
+
/** Strict loopback fence for every route (the panel is same-origin only). */
|
|
803
|
+
function isLoopbackRequest(request) {
|
|
804
|
+
const address = request.socket.remoteAddress;
|
|
805
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
806
|
+
const host = request.headers.host;
|
|
807
|
+
if (typeof host !== "string") return false;
|
|
808
|
+
let hostUrl;
|
|
809
|
+
try {
|
|
810
|
+
hostUrl = new URL(`http://${host}`);
|
|
811
|
+
} catch {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
815
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
816
|
+
const origin = request.headers.origin;
|
|
817
|
+
if (origin === void 0) return true;
|
|
818
|
+
try {
|
|
819
|
+
return new URL(origin).host === hostUrl.host;
|
|
820
|
+
} catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** One JSON response. */
|
|
825
|
+
function writeJson(res, status, body) {
|
|
826
|
+
res.writeHead(status, {
|
|
827
|
+
"content-type": "application/json; charset=utf-8",
|
|
828
|
+
"cache-control": "no-store",
|
|
829
|
+
"referrer-policy": "no-referrer"
|
|
830
|
+
});
|
|
831
|
+
res.end(JSON.stringify(body));
|
|
832
|
+
}
|
|
833
|
+
/** Read and parse a JSON request body (undefined when invalid). */
|
|
834
|
+
async function readJsonBody(request) {
|
|
835
|
+
const chunks = [];
|
|
836
|
+
let size = 0;
|
|
837
|
+
for await (const chunk of request) {
|
|
838
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
839
|
+
size += buffer.length;
|
|
840
|
+
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
841
|
+
chunks.push(buffer);
|
|
842
|
+
}
|
|
843
|
+
if (size === 0) return {};
|
|
844
|
+
try {
|
|
845
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
846
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
847
|
+
} catch {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Ask the helper (via its console port) for its live status. */
|
|
852
|
+
async function fetchHelperJson(url, timeoutMs = 2e3) {
|
|
853
|
+
const controller = new AbortController();
|
|
854
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
855
|
+
try {
|
|
856
|
+
const response = await fetch(url, {
|
|
857
|
+
signal: controller.signal,
|
|
858
|
+
cache: "no-store"
|
|
859
|
+
});
|
|
860
|
+
if (!response.ok) return null;
|
|
861
|
+
return await response.json();
|
|
862
|
+
} catch {
|
|
863
|
+
return null;
|
|
864
|
+
} finally {
|
|
865
|
+
clearTimeout(timer);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/** Build the route list for ctx.webServer.register. */
|
|
869
|
+
function makeRoutes(deps) {
|
|
870
|
+
const guard = (req, res, method) => {
|
|
871
|
+
if (!isLoopbackRequest(req)) {
|
|
872
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
if (req.method !== method) {
|
|
876
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
return true;
|
|
880
|
+
};
|
|
881
|
+
const queryOf = (req) => new URL(req.url ?? "/", "http://127.0.0.1").searchParams;
|
|
882
|
+
const intParam = (params, key, fallback, min, max) => {
|
|
883
|
+
const raw = Number(params.get(key) ?? "");
|
|
884
|
+
if (!Number.isFinite(raw)) return fallback;
|
|
885
|
+
return Math.max(min, Math.min(max, Math.floor(raw)));
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Whether a helper snapshot is about THIS host.
|
|
889
|
+
*
|
|
890
|
+
* The plugin home is shared by every DSH instance on the machine, so a fresh
|
|
891
|
+
* `status.json` with a live helper pid may describe a sibling instance's
|
|
892
|
+
* restart. A helper names the pid it replaced (`oldPid`) and the pid it
|
|
893
|
+
* started (`childPid`); one of them being us is the only proof it concerns our
|
|
894
|
+
* restart — freshness alone is not ownership.
|
|
895
|
+
*/
|
|
896
|
+
const belongsToThisHost = (status) => status !== null && (status.oldPid === process.pid || (status.childPid ?? null) === process.pid);
|
|
897
|
+
/**
|
|
898
|
+
* Live helper status.
|
|
899
|
+
*
|
|
900
|
+
* status.json is authoritative (it carries the helper pid, so a stale file
|
|
901
|
+
* from an earlier restart is detectable). Only when no live helper is on
|
|
902
|
+
* record do we probe a console port — and the responder must prove it is
|
|
903
|
+
* *our* helper for *this* host:
|
|
904
|
+
*
|
|
905
|
+
* 1. it identifies as dsh-restart,
|
|
906
|
+
* 2. it writes the status file belonging to this plugin home, and
|
|
907
|
+
* 3. it is about this process — either the pid that requested the restart
|
|
908
|
+
* (still running, restart in flight) or the pid it launched (we are the
|
|
909
|
+
* process it started).
|
|
910
|
+
*
|
|
911
|
+
* Without (2) and (3) anything shaped like a helper passes: a leftover helper
|
|
912
|
+
* from a different test/demo on the fallback port was reported as "the current
|
|
913
|
+
* restart is failing", which is exactly the kind of lie this surface must not
|
|
914
|
+
* tell.
|
|
915
|
+
*/
|
|
916
|
+
const helperState = async (config) => {
|
|
917
|
+
const fromFile = await readHelperStatus();
|
|
918
|
+
if (fromFile.alive && fromFile.status !== null && belongsToThisHost(fromFile.status)) return fromFile;
|
|
919
|
+
if (fromFile.alive && fromFile.status !== null && !belongsToThisHost(fromFile.status)) return {
|
|
920
|
+
status: null,
|
|
921
|
+
alive: false,
|
|
922
|
+
ageMs: fromFile.ageMs
|
|
923
|
+
};
|
|
924
|
+
const reported = fromFile.status?.fallbackPort;
|
|
925
|
+
const port = typeof reported === "number" && reported > 0 ? reported : config.fallbackPort;
|
|
926
|
+
const live = await fetchHelperJson(`http://${deps.host}:${port}/status`, 1200);
|
|
927
|
+
if (live !== null && live.helper === "dsh-restart" && live.statusFile === statusPath() && belongsToThisHost(live)) return {
|
|
928
|
+
status: live,
|
|
929
|
+
alive: true,
|
|
930
|
+
ageMs: 0
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
status: null,
|
|
934
|
+
alive: false,
|
|
935
|
+
ageMs: null
|
|
936
|
+
};
|
|
937
|
+
};
|
|
938
|
+
return [
|
|
939
|
+
{
|
|
940
|
+
kind: "exact",
|
|
941
|
+
path: RESTART_API.probe,
|
|
942
|
+
handler: (req, res) => {
|
|
943
|
+
if (!isLoopbackRequest(req)) {
|
|
944
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
948
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
writeJson(res, 200, {
|
|
952
|
+
ok: true,
|
|
953
|
+
pid: process.pid,
|
|
954
|
+
startedAt: (/* @__PURE__ */ new Date(Date.now() - process.uptime() * 1e3)).toISOString(),
|
|
955
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
kind: "exact",
|
|
961
|
+
path: RESTART_API.status,
|
|
962
|
+
handler: async (req, res) => {
|
|
963
|
+
if (!guard(req, res, "GET")) return;
|
|
964
|
+
const { config, exists, file } = await loadConfig();
|
|
965
|
+
const [host, helper, history, logs, job] = await Promise.all([
|
|
966
|
+
hostInfo(deps),
|
|
967
|
+
helperState(config),
|
|
968
|
+
readHistory(8),
|
|
969
|
+
listLogs(8),
|
|
970
|
+
config.restartMode === "helper" ? Promise.resolve(null) : launchdInfo()
|
|
971
|
+
]);
|
|
972
|
+
writeJson(res, 200, {
|
|
973
|
+
ok: true,
|
|
974
|
+
host,
|
|
975
|
+
helper: helper.status,
|
|
976
|
+
helperAlive: helper.alive,
|
|
977
|
+
helperAgeMs: helper.ageMs,
|
|
978
|
+
config,
|
|
979
|
+
configFile: file,
|
|
980
|
+
configExists: exists,
|
|
981
|
+
statusFile: statusPath(),
|
|
982
|
+
consoleUrl: `http://${deps.host}:${config.fallbackPort}`,
|
|
983
|
+
launchd: job === null ? null : {
|
|
984
|
+
managed: true,
|
|
985
|
+
label: job.label,
|
|
986
|
+
state: job.state,
|
|
987
|
+
logFile: job.stderrPath !== "" ? job.stderrPath : job.stdoutPath,
|
|
988
|
+
strategy: config.restartMode === "helper" ? "helper" : "launchd"
|
|
989
|
+
},
|
|
990
|
+
history,
|
|
991
|
+
logFiles: logs,
|
|
992
|
+
endpoints: RESTART_API
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
kind: "exact",
|
|
998
|
+
path: RESTART_API.restart,
|
|
999
|
+
handler: async (req, res) => {
|
|
1000
|
+
if (!guard(req, res, "POST")) return;
|
|
1001
|
+
const body = await readJsonBody(req) ?? {};
|
|
1002
|
+
const { config } = await loadConfig();
|
|
1003
|
+
const source = typeof body.source === "string" && body.source !== "" ? body.source : "web";
|
|
1004
|
+
const reason = typeof body.reason === "string" ? body.reason : "";
|
|
1005
|
+
const outcome = await requestRestart({
|
|
1006
|
+
config,
|
|
1007
|
+
port: deps.port,
|
|
1008
|
+
host: deps.host,
|
|
1009
|
+
url: deps.url,
|
|
1010
|
+
source,
|
|
1011
|
+
reason
|
|
1012
|
+
});
|
|
1013
|
+
if (!outcome.ok) {
|
|
1014
|
+
writeJson(res, 500, {
|
|
1015
|
+
...outcome,
|
|
1016
|
+
ok: false,
|
|
1017
|
+
error: outcome.error
|
|
1018
|
+
});
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
writeJson(res, 202, {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
helperPid: outcome.helperPid,
|
|
1024
|
+
logFile: outcome.logFile,
|
|
1025
|
+
statusFile: outcome.statusFile,
|
|
1026
|
+
fallbackPort: outcome.fallbackPort,
|
|
1027
|
+
fallbackUrl: outcome.fallbackUrl,
|
|
1028
|
+
exitInMs: outcome.exitInMs,
|
|
1029
|
+
mode: outcome.mode,
|
|
1030
|
+
restartingAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1031
|
+
oldPid: process.pid
|
|
1032
|
+
});
|
|
1033
|
+
outcome.commit();
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
kind: "exact",
|
|
1038
|
+
path: RESTART_API.logs,
|
|
1039
|
+
handler: async (req, res) => {
|
|
1040
|
+
if (!guard(req, res, "GET")) return;
|
|
1041
|
+
const params = queryOf(req);
|
|
1042
|
+
const { config } = await loadConfig();
|
|
1043
|
+
const lines = intParam(params, "lines", config.logLines, 10, 2e3);
|
|
1044
|
+
const which = (params.get("which") ?? "latest").trim();
|
|
1045
|
+
let file;
|
|
1046
|
+
if (which === "latest" || which === "auto") file = await newestLogFile();
|
|
1047
|
+
else if (which === "helper") file = statusPath().replace(/status\.json$/, "pending-spec.json");
|
|
1048
|
+
else file = which.startsWith("/") ? which : null;
|
|
1049
|
+
if (file === null) {
|
|
1050
|
+
writeJson(res, 200, {
|
|
1051
|
+
ok: true,
|
|
1052
|
+
file: "",
|
|
1053
|
+
exists: false,
|
|
1054
|
+
mtime: "",
|
|
1055
|
+
text: "",
|
|
1056
|
+
lines: [],
|
|
1057
|
+
errorLines: [],
|
|
1058
|
+
logFiles: await listLogs(8)
|
|
1059
|
+
});
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
writeJson(res, 200, {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
...await tailFile(file, lines),
|
|
1065
|
+
logFiles: await listLogs(8)
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
kind: "exact",
|
|
1071
|
+
path: RESTART_API.history,
|
|
1072
|
+
handler: async (req, res) => {
|
|
1073
|
+
if (!guard(req, res, "GET")) return;
|
|
1074
|
+
const { config } = await loadConfig();
|
|
1075
|
+
writeJson(res, 200, {
|
|
1076
|
+
ok: true,
|
|
1077
|
+
history: await readHistory(intParam(queryOf(req), "limit", 20, 1, config.historyLimit))
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
kind: "exact",
|
|
1083
|
+
path: RESTART_API.config,
|
|
1084
|
+
handler: async (req, res) => {
|
|
1085
|
+
if (!guard(req, res, "POST")) return;
|
|
1086
|
+
const body = await readJsonBody(req);
|
|
1087
|
+
if (body === void 0) {
|
|
1088
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
writeJson(res, 200, {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
config: body.reset === true ? await resetConfig() : await saveConfig(body),
|
|
1094
|
+
configFile: configPath()
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
kind: "exact",
|
|
1100
|
+
path: RESTART_API.helper,
|
|
1101
|
+
handler: async (req, res) => {
|
|
1102
|
+
if (!guard(req, res, "GET")) return;
|
|
1103
|
+
const { config } = await loadConfig();
|
|
1104
|
+
const state = await helperState(config);
|
|
1105
|
+
writeJson(res, 200, {
|
|
1106
|
+
ok: true,
|
|
1107
|
+
alive: state.alive,
|
|
1108
|
+
ageMs: state.ageMs,
|
|
1109
|
+
consoleUrl: `http://${deps.host}:${config.fallbackPort}`,
|
|
1110
|
+
status: state.status
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
kind: "exact",
|
|
1116
|
+
path: RESTART_API.helperRetry,
|
|
1117
|
+
handler: async (req, res) => {
|
|
1118
|
+
if (!guard(req, res, "POST")) return;
|
|
1119
|
+
const { config } = await loadConfig();
|
|
1120
|
+
const consoleUrl = `http://${deps.host}:${config.fallbackPort}`;
|
|
1121
|
+
const controller = new AbortController();
|
|
1122
|
+
const timer = setTimeout(() => controller.abort(), 2e3);
|
|
1123
|
+
try {
|
|
1124
|
+
writeJson(res, 200, {
|
|
1125
|
+
ok: (await fetch(`${consoleUrl}/retry`, {
|
|
1126
|
+
method: "POST",
|
|
1127
|
+
signal: controller.signal
|
|
1128
|
+
})).ok,
|
|
1129
|
+
consoleUrl
|
|
1130
|
+
});
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
writeJson(res, 502, {
|
|
1133
|
+
ok: false,
|
|
1134
|
+
error: `无法连接重启控制台 ${consoleUrl}:${String(error?.message ?? error)}`,
|
|
1135
|
+
consoleUrl
|
|
1136
|
+
});
|
|
1137
|
+
} finally {
|
|
1138
|
+
clearTimeout(timer);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
];
|
|
1143
|
+
}
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/tools.ts
|
|
1146
|
+
/** One text content block (the only render shape these tools emit). */
|
|
1147
|
+
function text(value) {
|
|
1148
|
+
return [{
|
|
1149
|
+
type: "text",
|
|
1150
|
+
text: value
|
|
1151
|
+
}];
|
|
1152
|
+
}
|
|
1153
|
+
/** Render the tool's `message` field. */
|
|
1154
|
+
function renderMessage(_args, value) {
|
|
1155
|
+
return text(String(value.message ?? ""));
|
|
1156
|
+
}
|
|
1157
|
+
/** Clamp a model-supplied integer. */
|
|
1158
|
+
function clampInt(value, fallback, min, max) {
|
|
1159
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
1160
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
1161
|
+
}
|
|
1162
|
+
/** One-line description of a helper phase. */
|
|
1163
|
+
function phaseLine(status) {
|
|
1164
|
+
if (status === null) return "";
|
|
1165
|
+
return `重启助手:${status.phase ?? "unknown"}(第 ${status.attempt ?? 1}/${status.maxAttempts ?? 1} 次尝试${typeof status.elapsedMs === "number" ? `,已 ${(status.elapsedMs / 1e3).toFixed(1)}s` : ""})`;
|
|
1166
|
+
}
|
|
1167
|
+
/** Tool: restart status, live helper state, last boot errors. */
|
|
1168
|
+
function restartStatusTool(ctx) {
|
|
1169
|
+
return defineTool({
|
|
1170
|
+
name: "dsh_restart_status",
|
|
1171
|
+
description: "查看 DSH 宿主与 dsh-restart 插件的重启状态:宿主 pid/端口/版本/启动时长/启动命令、是否有重启助手在运行(以及它当前处于哪个阶段、上次尝试的失败原因)、最近几次重启记录、以及最近一次启动日志里疑似报错的行。不会重启任何东西。",
|
|
1172
|
+
parameters: { lines: {
|
|
1173
|
+
type: "number",
|
|
1174
|
+
description: "启动日志返回的行数(默认 40,范围 10-400)"
|
|
1175
|
+
} },
|
|
1176
|
+
output: {
|
|
1177
|
+
schema: {
|
|
1178
|
+
type: "object",
|
|
1179
|
+
additionalProperties: false,
|
|
1180
|
+
properties: {
|
|
1181
|
+
ok: {
|
|
1182
|
+
type: "boolean",
|
|
1183
|
+
required: true
|
|
1184
|
+
},
|
|
1185
|
+
message: {
|
|
1186
|
+
type: "string",
|
|
1187
|
+
required: true
|
|
1188
|
+
},
|
|
1189
|
+
pid: { type: "number" },
|
|
1190
|
+
port: { type: "number" },
|
|
1191
|
+
url: { type: "string" },
|
|
1192
|
+
dshVersion: { type: "string" },
|
|
1193
|
+
nodeVersion: { type: "string" },
|
|
1194
|
+
uptimeMs: { type: "number" },
|
|
1195
|
+
command: { type: "string" },
|
|
1196
|
+
helperAlive: { type: "boolean" },
|
|
1197
|
+
helperPhase: { type: "string" },
|
|
1198
|
+
helperFailure: { type: "string" },
|
|
1199
|
+
consoleUrl: { type: "string" },
|
|
1200
|
+
logFile: { type: "string" },
|
|
1201
|
+
errorLines: { type: "array" },
|
|
1202
|
+
history: { type: "array" }
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
render: renderMessage
|
|
1206
|
+
},
|
|
1207
|
+
async execute(args) {
|
|
1208
|
+
const lines = clampInt((args ?? {}).lines, 40, 10, 400);
|
|
1209
|
+
const { config } = await loadConfig();
|
|
1210
|
+
const host = await hostInfo(ctx.endpoint);
|
|
1211
|
+
const helper = await readHelperStatus();
|
|
1212
|
+
const logFile = await newestLogFile();
|
|
1213
|
+
const tail = logFile === null ? {
|
|
1214
|
+
file: "",
|
|
1215
|
+
exists: false,
|
|
1216
|
+
errorLines: [],
|
|
1217
|
+
lines: [],
|
|
1218
|
+
text: "",
|
|
1219
|
+
mtime: ""
|
|
1220
|
+
} : await tailFile(logFile, lines);
|
|
1221
|
+
const history = await readHistory(5);
|
|
1222
|
+
const failure = helper.status?.failure?.message ?? "";
|
|
1223
|
+
return {
|
|
1224
|
+
ok: true,
|
|
1225
|
+
message: "dsh-restart:" + [
|
|
1226
|
+
`宿主:pid ${host.pid},${host.url},DSH ${host.dshVersion || "未知版本"},Node ${host.nodeVersion}`,
|
|
1227
|
+
`已运行 ${(host.uptimeMs / 1e3).toFixed(0)}s`,
|
|
1228
|
+
helper.alive ? phaseLine(helper.status) : "当前没有重启助手在运行",
|
|
1229
|
+
failure === "" ? "" : `上次重启失败:${failure}`,
|
|
1230
|
+
tail.exists && tail.errorLines.length > 0 ? `上次启动日志有 ${tail.errorLines.length} 行疑似报错(${tail.file})` : tail.exists ? "上次启动日志未发现明显报错" : "暂无启动日志"
|
|
1231
|
+
].filter((part) => part !== "").join(";") + "。",
|
|
1232
|
+
pid: host.pid,
|
|
1233
|
+
port: host.port,
|
|
1234
|
+
url: host.url,
|
|
1235
|
+
dshVersion: host.dshVersion,
|
|
1236
|
+
nodeVersion: host.nodeVersion,
|
|
1237
|
+
uptimeMs: host.uptimeMs,
|
|
1238
|
+
command: host.command,
|
|
1239
|
+
helperAlive: helper.alive,
|
|
1240
|
+
helperPhase: helper.status?.phase ?? "",
|
|
1241
|
+
helperFailure: failure,
|
|
1242
|
+
consoleUrl: `http://${ctx.endpoint.host}:${config.fallbackPort}`,
|
|
1243
|
+
logFile: tail.file,
|
|
1244
|
+
errorLines: tail.errorLines.slice(-25),
|
|
1245
|
+
history: history.map((record) => `${record.at} · ${record.source}${record.reason === "" ? "" : "(" + record.reason + ")"} · pid ${record.oldPid} → 助手 ${record.helperPid ?? "—"} · ${record.outcome ?? "unknown"}`)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
/** Tool: restart the host. */
|
|
1251
|
+
function restartTool(ctx) {
|
|
1252
|
+
return defineTool({
|
|
1253
|
+
name: "dsh_restart",
|
|
1254
|
+
description: "重启 DSH 宿主(网页端会自动重连并刷新):把重启交给一个分离的重启助手,它等端口释放后用完全相同的命令重新拉起 DSH,并在此期间提供一个恢复控制台(默认 http://127.0.0.1:3099)显示启动进度与报错。**重启会立即中断当前回合与当前会话的连接**,因此必须先获得用户明确同意再调用,并设置 confirm: true;未确认时本工具只返回提示不执行。安装/更新插件后需要让新代码生效时用本工具。",
|
|
1255
|
+
parameters: {
|
|
1256
|
+
confirm: {
|
|
1257
|
+
type: "boolean",
|
|
1258
|
+
description: "必须为 true 才真正执行(表示已获得用户同意)"
|
|
1259
|
+
},
|
|
1260
|
+
reason: {
|
|
1261
|
+
type: "string",
|
|
1262
|
+
description: "重启原因(会记录进重启历史)"
|
|
1263
|
+
},
|
|
1264
|
+
delayMs: {
|
|
1265
|
+
type: "number",
|
|
1266
|
+
description: "回包后多少毫秒再退出本进程(默认 1500,给当前回合留出落盘时间)"
|
|
1267
|
+
}
|
|
1268
|
+
},
|
|
1269
|
+
output: {
|
|
1270
|
+
schema: {
|
|
1271
|
+
type: "object",
|
|
1272
|
+
additionalProperties: false,
|
|
1273
|
+
properties: {
|
|
1274
|
+
ok: {
|
|
1275
|
+
type: "boolean",
|
|
1276
|
+
required: true
|
|
1277
|
+
},
|
|
1278
|
+
message: {
|
|
1279
|
+
type: "string",
|
|
1280
|
+
required: true
|
|
1281
|
+
},
|
|
1282
|
+
helperPid: { type: "number" },
|
|
1283
|
+
logFile: { type: "string" },
|
|
1284
|
+
consoleUrl: { type: "string" },
|
|
1285
|
+
exitInMs: { type: "number" },
|
|
1286
|
+
scheduled: { type: "boolean" }
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
render: renderMessage
|
|
1290
|
+
},
|
|
1291
|
+
async execute(args) {
|
|
1292
|
+
const input = args ?? {};
|
|
1293
|
+
if (input.confirm !== true) return {
|
|
1294
|
+
ok: false,
|
|
1295
|
+
scheduled: false,
|
|
1296
|
+
message: "未执行重启:dsh_restart 需要 confirm: true。请先用 ask_user_question(或口头)征得用户明确同意——本机铁律规定未经同意不得重启 DSH。",
|
|
1297
|
+
helperPid: 0,
|
|
1298
|
+
logFile: "",
|
|
1299
|
+
consoleUrl: "",
|
|
1300
|
+
exitInMs: 0
|
|
1301
|
+
};
|
|
1302
|
+
const { config } = await loadConfig();
|
|
1303
|
+
const reason = typeof input.reason === "string" && input.reason !== "" ? input.reason : "agent tool";
|
|
1304
|
+
const exitDelayMs = clampInt(input.delayMs, 1500, 200, 3e4);
|
|
1305
|
+
const outcome = await requestRestart({
|
|
1306
|
+
config,
|
|
1307
|
+
port: ctx.endpoint.port,
|
|
1308
|
+
host: ctx.endpoint.host,
|
|
1309
|
+
url: ctx.endpoint.url,
|
|
1310
|
+
source: "agent",
|
|
1311
|
+
reason,
|
|
1312
|
+
exitDelayMs
|
|
1313
|
+
});
|
|
1314
|
+
if (!outcome.ok) return {
|
|
1315
|
+
ok: false,
|
|
1316
|
+
scheduled: false,
|
|
1317
|
+
message: "重启失败(未执行):" + outcome.error,
|
|
1318
|
+
helperPid: 0,
|
|
1319
|
+
logFile: outcome.logFile,
|
|
1320
|
+
consoleUrl: outcome.fallbackUrl,
|
|
1321
|
+
exitInMs: 0
|
|
1322
|
+
};
|
|
1323
|
+
outcome.commit();
|
|
1324
|
+
return {
|
|
1325
|
+
ok: true,
|
|
1326
|
+
scheduled: true,
|
|
1327
|
+
message: `已安排重启(原因:${reason};方式:${outcome.mode === "launchd" ? "launchd 托管重启" : "分离助手自拉起"})。重启助手 pid ${outcome.helperPid ?? "?"}${outcome.mode === "launchd" ? ",由助手在回包后 kickstart 托管任务" : `,约 ${outcome.exitInMs}ms 后本进程退出`};新宿主启动日志:${outcome.logFile};若启动失败,恢复控制台 http://${ctx.endpoint.host}:${outcome.fallbackPort} 会显示报错。当前回合将随进程结束而中断,网页端会自动重连刷新。`,
|
|
1328
|
+
helperPid: outcome.helperPid ?? 0,
|
|
1329
|
+
logFile: outcome.logFile,
|
|
1330
|
+
consoleUrl: outcome.fallbackUrl,
|
|
1331
|
+
exitInMs: outcome.exitInMs
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
/** Build the tool roster. */
|
|
1337
|
+
function buildTools(ctx) {
|
|
1338
|
+
return [restartStatusTool(ctx), restartTool(ctx)];
|
|
1339
|
+
}
|
|
1340
|
+
//#endregion
|
|
1341
|
+
//#region src/index.ts
|
|
1342
|
+
/** Stable cordis plugin name. */
|
|
1343
|
+
const name = "dsh-restart";
|
|
1344
|
+
/** Services required before the plugin surfaces can mount. */
|
|
1345
|
+
const inject = [
|
|
1346
|
+
"tools",
|
|
1347
|
+
"systemPrompt",
|
|
1348
|
+
"webServer"
|
|
1349
|
+
];
|
|
1350
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
1351
|
+
const SECTION_ORDER = 216;
|
|
1352
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
1353
|
+
const RESTART_GUIDANCE = "本机已安装 @zhengjunyao/dsh-restart 插件(一键重启 DSH):装完/更新插件后不必再去终端重启——Web GUI 侧边栏有「重启」入口、设置页有「重启」卡片,点一下即把重启交给一个**分离的重启助手**(等端口释放后用完全相同的命令重新拉起 DSH),网页会自动重连并刷新;失败时该插件会直接把报错显示出来(页面内置重启遮罩 + 恢复控制台 http://127.0.0.1:3099,含启动日志与检测到的报错行),不必去翻终端日志。Agent 侧工具:dsh_restart_status(查看宿主 pid/端口/版本/启动时长/启动命令、重启助手阶段与失败原因、最近重启记录、上次启动日志里的疑似报错行——只读)、dsh_restart(真正重启,**必须已获得用户明确同意**并传 confirm: true,否则只返回提示不执行;重启会中断当前回合与连接,网页端自动重连)。配置存 ~/.dsh/dsh-restart.json(0600),日志在 ~/.dsh/dsh-restart/logs/。注意:本机铁律规定**未经用户同意不得重启或关闭 DSH**,所以即使装了本插件,也要先问过用户再调用 dsh_restart。用户提到「重启 / 重启一下 / 重启 DSH / 重载插件 / 一键重启」时即指本插件,请据此协作。";
|
|
1354
|
+
/**
|
|
1355
|
+
* Mount the restart routes, tools and announcement.
|
|
1356
|
+
* @param ctx - host plugin context carrying tools/systemPrompt/webServer.
|
|
1357
|
+
* @param config - plugin config from the composition row (seeds the JSON file).
|
|
1358
|
+
*/
|
|
1359
|
+
function apply(ctx, config) {
|
|
1360
|
+
if (!(config?.enabled !== false)) return;
|
|
1361
|
+
seedConfigSync(config ?? {});
|
|
1362
|
+
const announceToAgent = config?.announceToAgent !== false;
|
|
1363
|
+
/**
|
|
1364
|
+
* Live endpoint view. Read through getters: the plugin mounts while the
|
|
1365
|
+
* web server is initialized but the port may only be final once it listens,
|
|
1366
|
+
* and a restart must always target the port actually in use.
|
|
1367
|
+
*/
|
|
1368
|
+
const endpoint = {
|
|
1369
|
+
get port() {
|
|
1370
|
+
const live = ctx.webServer.port;
|
|
1371
|
+
if (Number.isInteger(live) && live > 0) return live;
|
|
1372
|
+
const fromEnv = Number(process.env.DSH_PORT ?? "");
|
|
1373
|
+
return Number.isInteger(fromEnv) && fromEnv > 0 ? fromEnv : 3080;
|
|
1374
|
+
},
|
|
1375
|
+
get host() {
|
|
1376
|
+
return "127.0.0.1";
|
|
1377
|
+
},
|
|
1378
|
+
get url() {
|
|
1379
|
+
return `http://127.0.0.1:${this.port}`;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
ctx.effect(() => {
|
|
1383
|
+
const disposers = makeRoutes(endpoint).map((route) => ctx.webServer.register(route));
|
|
1384
|
+
return () => {
|
|
1385
|
+
for (const dispose of disposers) dispose();
|
|
1386
|
+
};
|
|
1387
|
+
}, "dsh-restart: routes");
|
|
1388
|
+
ctx.effect(() => {
|
|
1389
|
+
const disposers = buildTools({ endpoint }).map((tool) => ctx.tools.register(tool));
|
|
1390
|
+
return () => {
|
|
1391
|
+
for (const dispose of disposers) dispose();
|
|
1392
|
+
};
|
|
1393
|
+
}, "dsh-restart: tools");
|
|
1394
|
+
if (announceToAgent) ctx.effect(() => ctx.systemPrompt.section({
|
|
1395
|
+
name: "plugin:dsh-restart",
|
|
1396
|
+
order: SECTION_ORDER,
|
|
1397
|
+
text: RESTART_GUIDANCE
|
|
1398
|
+
}), "dsh-restart: prompt section");
|
|
1399
|
+
}
|
|
1400
|
+
//#endregion
|
|
1401
|
+
export { DEFAULT_CONFIG, RESTART_API, RESTART_GUIDANCE, appendHistory, apply, buildSpec, buildTools, configPath, detectLaunchd, detectLaunchdFor, ensureLayout, helperPath, historyPath, hostInfo, inject, isAlive, kickCommand, kickstart, labelForPid, launchSignature, launchdInfo, listLogs, loadConfig, loadConfigSync, logsDir, makeRoutes, name, newestLogFile, normalizeConfig, readHelperStatus, readHistory, readLaunchdLog, requestRestart, resetConfig, restartHome, restartStatusTool, restartTool, saveConfig, scheduleSelfExit, seedConfigSync, specPath, statusPath, tailFile };
|