dsh-update-status 0.3.2 → 0.4.1

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.
@@ -0,0 +1,746 @@
1
+ import { createRequire } from "node:module";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
4
+ import { homedir } from "node:os";
5
+ //#region src/shared/restart-evidence.ts
6
+ /**
7
+ * First-party evidence that a DSH Web Host is being restarted in a storm.
8
+ *
9
+ * A restart is only safe when the supervisor's next start can actually bind the
10
+ * port. When a second launcher owns the port (another plugin's restart helper, a
11
+ * `nohup` script), the supervisor keeps respawning a process that dies with
12
+ * `EADDRINUSE` every few seconds — forever, because `KeepAlive`/`Restart=` has no
13
+ * failure ceiling of its own. Two files make that visible to whichever process
14
+ * is still alive:
15
+ *
16
+ * - one boot stamp appended by every Host generation that reaches this plugin;
17
+ * - DSH's own `startup-*.log` diagnostics, written by every process that fails
18
+ * to boot (a failing generation never composes this plugin, so it cannot leave
19
+ * a stamp of its own).
20
+ *
21
+ * Counting both is deliberate: stamps catch a loop of *successful* short-lived
22
+ * boots, diagnostics catch a loop of *failed* ones. Neither is a security
23
+ * boundary — they exist to stop a destructive restart button from lying.
24
+ */
25
+ const BOOT_STAMP_FILE = "dsh-update-status-boots.log";
26
+ /** A window this short can only fill up when starts are failing back to back. */
27
+ const RESTART_STORM_WINDOW_MS = 18e4;
28
+ /** DSH's own per-process failure diagnostics, written into the same log dir. */
29
+ const STARTUP_DIAGNOSTIC = /^startup-.*\.log$/;
30
+ const BOOT_STAMP_MAX_BYTES = 32768;
31
+ function dshHomeOf(env = process.env) {
32
+ const configured = env.DSH_HOME?.trim();
33
+ if (configured !== void 0 && configured !== "") return configured;
34
+ const home = env.HOME?.trim();
35
+ return join(home !== void 0 && home !== "" ? home : homedir(), ".dsh");
36
+ }
37
+ function serviceLogDir(env = process.env) {
38
+ return join(dshHomeOf(env), "logs");
39
+ }
40
+ function bootStampFile(env = process.env) {
41
+ return join(serviceLogDir(env), BOOT_STAMP_FILE);
42
+ }
43
+ /**
44
+ * Append one line, best effort. A Host must never fail to start because its own
45
+ * restart evidence could not be written, so every filesystem error is swallowed.
46
+ */
47
+ function recordBootStamp(path, stamp) {
48
+ try {
49
+ mkdirSync(dirname(path), {
50
+ recursive: true,
51
+ mode: 448
52
+ });
53
+ trimBootStamps(path);
54
+ appendFileSync(path, `${JSON.stringify({
55
+ at: stamp.at ?? Date.now(),
56
+ pid: stamp.pid,
57
+ instanceId: stamp.instanceId,
58
+ supervisor: stamp.supervisor
59
+ })}\n`, {
60
+ encoding: "utf8",
61
+ mode: 384
62
+ });
63
+ } catch {}
64
+ }
65
+ function trimBootStamps(path) {
66
+ if (!existsSync(path) || statSync(path).size <= BOOT_STAMP_MAX_BYTES) return;
67
+ const kept = readFileSync(path, "utf8").split("\n").filter((line) => line.trim() !== "").slice(-100);
68
+ writeFileSync(path, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
69
+ encoding: "utf8",
70
+ mode: 384
71
+ });
72
+ }
73
+ function readBootStamps(path) {
74
+ let text;
75
+ try {
76
+ text = readFileSync(path, "utf8");
77
+ } catch {
78
+ return [];
79
+ }
80
+ const stamps = [];
81
+ for (const line of text.split("\n")) {
82
+ if (line.trim() === "") continue;
83
+ try {
84
+ const value = JSON.parse(line);
85
+ if (typeof value.at !== "number" || typeof value.pid !== "number" || typeof value.instanceId !== "string") continue;
86
+ stamps.push({
87
+ at: value.at,
88
+ pid: value.pid,
89
+ instanceId: value.instanceId,
90
+ supervisor: typeof value.supervisor === "string" ? value.supervisor : null
91
+ });
92
+ } catch {}
93
+ }
94
+ return stamps;
95
+ }
96
+ function countRecentBootStamps(stamps, now, windowMs) {
97
+ let count = 0;
98
+ for (const stamp of stamps) {
99
+ const age = now - stamp.at;
100
+ if (age >= -1e3 && age <= windowMs) count += 1;
101
+ }
102
+ return count;
103
+ }
104
+ /** DSH writes one `startup-*.log` per process that failed to boot. */
105
+ function countRecentStartupFailures(logDir, now, windowMs) {
106
+ let names;
107
+ try {
108
+ names = readdirSync(logDir);
109
+ } catch {
110
+ return 0;
111
+ }
112
+ let count = 0;
113
+ for (const name of names) {
114
+ if (!STARTUP_DIAGNOSTIC.test(name)) continue;
115
+ try {
116
+ const age = now - statSync(join(logDir, name)).mtimeMs;
117
+ if (age >= -1e3 && age <= windowMs) count += 1;
118
+ } catch {}
119
+ }
120
+ return count;
121
+ }
122
+ /**
123
+ * Both halves of the storm signal, for callers that only have the paths — the
124
+ * Host's health probe and the service CLI's post-activation check share it.
125
+ */
126
+ function countRecentRestarts(logDir, bootLog, now, windowMs = RESTART_STORM_WINDOW_MS) {
127
+ return countRecentBootStamps(readBootStamps(bootLog), now, windowMs) + countRecentStartupFailures(logDir, now, windowMs);
128
+ }
129
+ //#endregion
130
+ //#region src/shared/service-platform.ts
131
+ /**
132
+ * How each platform reports the state of the user service this plugin stages.
133
+ *
134
+ * Shared between the Host's ownership probe and the CLI's post-activation check
135
+ * so both read the same output with the same rules; the strings are the only
136
+ * stable interface those commands offer.
137
+ */
138
+ /**
139
+ * The launchd job this plugin stages. It is a fixed constant rather than something
140
+ * read back from the environment: the job environment launchd configures does
141
+ * carry `XPC_SERVICE_NAME=<label>`, but that value does not survive to the DSH
142
+ * process behind the staged `/bin/sh` wrapper — the Host reads `XPC_SERVICE_NAME=0`
143
+ * — so ownership must be asked of launchd by label.
144
+ */
145
+ const LAUNCHD_LABEL = "com.idoall.dsh-update-status.web";
146
+ const SYSTEMD_UNIT = "dsh-update-status-web.service";
147
+ const WINDOWS_TASK_NAME = "DSH Update Status Web";
148
+ const WINDOWS_WRAPPER_BASENAME = "dsh-web-supervisor.ps1";
149
+ /** The first `pid = N` of `launchctl print` output is the job's tracked process. */
150
+ function parseLaunchdTrackedPid(output) {
151
+ const match = /(?:^|\n)[^\S\n]*pid = (\d+)/.exec(output);
152
+ if (match?.[1] === void 0) return void 0;
153
+ const pid = Number(match[1]);
154
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
155
+ }
156
+ /** `systemctl --user show -p MainPID --value` prints the pid alone, or `0`. */
157
+ function parseSystemdMainPid(output) {
158
+ const value = output.trim().split("\n")[0]?.trim() ?? "";
159
+ if (!/^\d+$/.test(value)) return void 0;
160
+ const pid = Number(value);
161
+ return pid > 0 ? pid : void 0;
162
+ }
163
+ /** `(Get-ScheduledTask -TaskName ...).State` names its state in English. */
164
+ function parseWindowsTaskRunning(output) {
165
+ return /(?:^|\W)running(?:\W|$)/i.test(output);
166
+ }
167
+ //#endregion
168
+ //#region src/service/service-path.ts
169
+ /** Resolve one audited PATH that preflight and the native service both consume. */
170
+ /** A service definition must stay inspectable; never silently truncate it. */
171
+ const MAX_SERVICE_PATH_LENGTH = 8192;
172
+ const MINIMAL_POSIX = {
173
+ darwin: [
174
+ "/usr/local/bin",
175
+ "/usr/bin",
176
+ "/bin",
177
+ "/usr/sbin",
178
+ "/sbin"
179
+ ],
180
+ linux: [
181
+ "/usr/local/bin",
182
+ "/usr/bin",
183
+ "/bin"
184
+ ]
185
+ };
186
+ const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD";
187
+ function normalizeServicePathExt(value) {
188
+ const raw = value?.trim() || DEFAULT_WINDOWS_PATHEXT;
189
+ validateText(raw);
190
+ const seen = /* @__PURE__ */ new Set();
191
+ const extensions = [];
192
+ for (const original of raw.split(";")) {
193
+ const extension = original.trim();
194
+ if (extension === "") continue;
195
+ if (!/^\.[A-Za-z0-9]+$/.test(extension)) throw new Error(`invalid PATHEXT entry: ${extension}`);
196
+ const key = extension.toUpperCase();
197
+ if (seen.has(key)) continue;
198
+ seen.add(key);
199
+ extensions.push(extension);
200
+ }
201
+ if (extensions.length === 0) throw new Error("PATHEXT has no usable extension");
202
+ return extensions.join(";");
203
+ }
204
+ function delimiter(platform) {
205
+ return platform === "win32" ? ";" : ":";
206
+ }
207
+ function nodeDirectory(platform, nodePath) {
208
+ return platform === "win32" ? win32.dirname(nodePath) : posix.dirname(nodePath);
209
+ }
210
+ function isAbsolute$1(platform, value) {
211
+ return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
212
+ }
213
+ function minimalEntries(platform, nodePath, override) {
214
+ const node = nodeDirectory(platform, nodePath);
215
+ if (override !== void 0 && override.trim() !== "") return [node, ...override.split(delimiter(platform))];
216
+ if (platform === "win32") return [node];
217
+ return [node, ...MINIMAL_POSIX[platform]];
218
+ }
219
+ function sourceValue(options) {
220
+ if (options.source === "minimal") return minimalEntries(options.platform, options.nodePath, options.minimalPath).join(delimiter(options.platform));
221
+ if (options.source === "explicit") {
222
+ if (options.explicitPath === void 0 || options.explicitPath.trim() === "") throw new Error("`--service-path` is required when `--path-source explicit` is used");
223
+ return options.explicitPath;
224
+ }
225
+ if (options.currentPath === void 0 || options.currentPath.trim() === "") throw new Error("current PATH is empty; use `--path-source minimal` or `--service-path`");
226
+ return options.currentPath;
227
+ }
228
+ function validateText(value) {
229
+ if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) throw new Error("service PATH must not contain control characters or line breaks");
230
+ if (value.length > 8192) throw new Error(`service PATH is too long (maximum ${String(MAX_SERVICE_PATH_LENGTH)} characters); use --service-path with a shorter value`);
231
+ }
232
+ /**
233
+ * Resolve at install/plan time — no user rc file is executed. Missing absolute
234
+ * directories are preserved (they may be mounted or created later) and reported.
235
+ */
236
+ function resolveServicePath(options) {
237
+ const split = delimiter(options.platform);
238
+ const raw = sourceValue(options);
239
+ validateText(raw);
240
+ if (options.platform !== "win32" && /(?:^|;)[A-Za-z]:[\\/]/.test(raw)) throw new Error("Windows-style PATH cannot be used for Linux/WSL; provide a colon-separated PATH");
241
+ const exists = options.exists ?? existsSync;
242
+ const warnings = [];
243
+ const values = [nodeDirectory(options.platform, options.nodePath), ...raw.split(split)];
244
+ const seen = /* @__PURE__ */ new Set();
245
+ const entries = [];
246
+ let warnedEmpty = false;
247
+ for (const rawEntry of values) {
248
+ if (rawEntry === "") {
249
+ if (!warnedEmpty) warnings.push("ignored empty PATH entry (current-directory lookup)");
250
+ warnedEmpty = true;
251
+ continue;
252
+ }
253
+ const entry = options.platform === "win32" && rawEntry.length >= 2 && rawEntry.startsWith("\"") && rawEntry.endsWith("\"") ? rawEntry.slice(1, -1) : rawEntry;
254
+ if (!isAbsolute$1(options.platform, entry)) {
255
+ warnings.push(`ignored relative PATH entry: ${entry}`);
256
+ continue;
257
+ }
258
+ const key = options.platform === "win32" ? entry.toLowerCase() : entry;
259
+ if (seen.has(key)) continue;
260
+ seen.add(key);
261
+ entries.push(entry);
262
+ if (!exists(entry)) warnings.push(`PATH directory does not exist yet: ${entry}`);
263
+ }
264
+ const value = entries.join(split);
265
+ if (value === "") throw new Error("service PATH has no absolute directory");
266
+ validateText(value);
267
+ const pathExt = options.platform === "win32" ? normalizeServicePathExt(options.currentPathExt) : void 0;
268
+ return {
269
+ source: options.source,
270
+ value,
271
+ ...pathExt === void 0 ? {} : { pathExt },
272
+ warnings
273
+ };
274
+ }
275
+ /** Old receipts had no PATH fields; keep their exact historical minimal shape. */
276
+ function servicePathOf(spec) {
277
+ return spec.servicePath ?? minimalEntries(spec.platform, spec.nodePath).join(delimiter(spec.platform));
278
+ }
279
+ //#endregion
280
+ //#region src/service/plan.ts
281
+ /** Build transparent per-OS user-service definitions; do not install them here. */
282
+ const SERVICE_LABEL = LAUNCHD_LABEL;
283
+ const SUPERVISOR_MARKER = "dsh-update-status";
284
+ function nativeServiceCommandCandidates(platform, systemRoot) {
285
+ if (platform === "darwin") return ["/bin/launchctl"];
286
+ if (platform === "linux") return ["/usr/bin/systemctl", "/bin/systemctl"];
287
+ return [`${systemRoot ?? "C:\\Windows"}\\System32\\schtasks.exe`];
288
+ }
289
+ function nativeServiceCommandOf(spec) {
290
+ return spec.nativeServiceCommand ?? nativeServiceCommandCandidates(spec.platform)[0];
291
+ }
292
+ function xml(value) {
293
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
294
+ }
295
+ function systemd(value) {
296
+ return value.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
297
+ }
298
+ function powerShell(value) {
299
+ return value.replace(/'/g, "''");
300
+ }
301
+ function shQuote(value) {
302
+ return `'${value.replace(/'/g, "'\\''")}'`;
303
+ }
304
+ function boundedPort(value) {
305
+ const port = typeof value === "number" ? value : Number(value);
306
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("`--port` must be an integer from 1 to 65535");
307
+ return port;
308
+ }
309
+ function valueAfter(args, flag) {
310
+ const index = args.indexOf(flag);
311
+ if (index < 0) return void 0;
312
+ const value = args[index + 1];
313
+ if (value === void 0 || value.startsWith("--")) throw new Error(`missing value after ${flag}`);
314
+ return value;
315
+ }
316
+ /**
317
+ * Resolve the real JS file behind `@deepseek-ai/dsh`'s `bin.dsh`, using the
318
+ * selected profile as a Node resolution anchor. This avoids platform shell
319
+ * shims (`dsh.cmd`) and keeps every native service on `node <absolute-js>`.
320
+ */
321
+ function dshPathOf(env, profile, dshHome, override) {
322
+ const fromArg = override?.trim();
323
+ if (fromArg !== void 0 && fromArg !== "") return isAbsolute(fromArg) ? fromArg : resolve(env.cwd, fromArg);
324
+ const home = dshHome ?? join(env.home || homedir(), ".dsh");
325
+ try {
326
+ const anchor = join(home, "profiles", profile, "dsh-update-status-service-resolver.cjs");
327
+ const manifestPath = createRequire(anchor).resolve("@deepseek-ai/dsh/package.json");
328
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
329
+ if (manifest.name !== "@deepseek-ai/dsh" || typeof manifest.bin?.dsh !== "string") throw new Error("invalid dsh package manifest");
330
+ return realpathSync(join(dirname(manifestPath), manifest.bin.dsh));
331
+ } catch (error) {
332
+ throw new Error(`cannot resolve @deepseek-ai/dsh from profile ${profile}; rerun with --dsh /absolute/path/to/lib/bin.js (${error instanceof Error ? error.message : String(error)})`);
333
+ }
334
+ }
335
+ function servicePlatform(value) {
336
+ if (value === "darwin" || value === "linux" || value === "win32") return value;
337
+ throw new Error(`unsupported platform: ${value}; supported: macOS, Linux, Windows`);
338
+ }
339
+ function envValue(env, key, platform) {
340
+ if (platform !== "win32") return env[key];
341
+ return Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1];
342
+ }
343
+ function pathSourceOf(args) {
344
+ const explicit = valueAfter(args, "--service-path");
345
+ const raw = valueAfter(args, "--path-source");
346
+ if (raw === void 0) return explicit === void 0 ? "current" : "explicit";
347
+ if (raw !== "current" && raw !== "minimal" && raw !== "explicit") throw new Error("`--path-source` must be current, minimal, or explicit");
348
+ if (explicit !== void 0 && raw !== "explicit") throw new Error("`--service-path` requires `--path-source explicit` (or omit --path-source)");
349
+ return raw;
350
+ }
351
+ function serviceStateFileFromArgs(args, env) {
352
+ const home = env.home || homedir();
353
+ const dshHome = valueAfter(args, "--dsh-home") ?? (env.env.DSH_HOME?.trim() || void 0);
354
+ if (dshHome !== void 0 && !isAbsolute(dshHome)) throw new Error("`--dsh-home` must be an absolute path");
355
+ return join(dshHome ?? join(home, ".dsh"), "dsh-update-status-service.json");
356
+ }
357
+ function specFromArgs(args, env) {
358
+ const platform = servicePlatform(env.platform);
359
+ const profile = valueAfter(args, "--profile") ?? env.env.DSH_PROFILE ?? "web";
360
+ const workspace = valueAfter(args, "--workspace");
361
+ if (workspace === void 0) throw new Error("`--workspace` is required and must be the absolute directory DSH should use");
362
+ const host = valueAfter(args, "--host") ?? "127.0.0.1";
363
+ const port = boundedPort(valueAfter(args, "--port") ?? 3080);
364
+ const home = env.home || homedir();
365
+ const dshHome = valueAfter(args, "--dsh-home") ?? (env.env.DSH_HOME?.trim() || void 0);
366
+ if (dshHome !== void 0 && !isAbsolute(dshHome)) throw new Error("`--dsh-home` must be an absolute path");
367
+ const logDir = valueAfter(args, "--log-dir") ?? join(dshHome ?? join(home, ".dsh"), "logs");
368
+ const dshPath = dshPathOf(env, profile, dshHome, valueAfter(args, "--dsh"));
369
+ const nodePath = valueAfter(args, "--node") ?? env.execPath;
370
+ if (!isAbsolute(nodePath)) throw new Error("`--node` must be an absolute executable path");
371
+ if (!isAbsolute(dshPath)) throw new Error("`--dsh` must be an absolute executable path");
372
+ if (!isAbsolute(workspace)) throw new Error("`--workspace` must be an absolute path");
373
+ if (!isAbsolute(logDir)) throw new Error("`--log-dir` must be an absolute path");
374
+ const systemRoot = envValue(env.env, "SYSTEMROOT", platform);
375
+ const pathExtOverride = valueAfter(args, "--service-pathext");
376
+ if (pathExtOverride !== void 0 && platform !== "win32") throw new Error("`--service-pathext` is supported only on Windows");
377
+ const candidates = nativeServiceCommandCandidates(platform, systemRoot);
378
+ const nativeServiceCommand = platform === "linux" ? candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] : candidates[0];
379
+ const servicePath = resolveServicePath({
380
+ platform,
381
+ nodePath,
382
+ source: pathSourceOf(args),
383
+ currentPath: env.path ?? envValue(env.env, "PATH", platform),
384
+ currentPathExt: pathExtOverride ?? envValue(env.env, "PATHEXT", platform),
385
+ explicitPath: valueAfter(args, "--service-path"),
386
+ minimalPath: platform === "win32" && systemRoot !== void 0 ? `${systemRoot}\\System32;${systemRoot}` : void 0
387
+ });
388
+ return {
389
+ schemaVersion: 1,
390
+ platform,
391
+ label: SERVICE_LABEL,
392
+ nodePath,
393
+ dshPath,
394
+ profile,
395
+ workspace,
396
+ host,
397
+ port,
398
+ home,
399
+ ...dshHome === void 0 ? {} : { dshHome },
400
+ logDir,
401
+ servicePath: servicePath.value,
402
+ servicePathSource: servicePath.source,
403
+ servicePathCapturedAt: (/* @__PURE__ */ new Date()).toISOString(),
404
+ ...servicePath.pathExt === void 0 ? {} : { servicePathExt: servicePath.pathExt },
405
+ ...servicePath.warnings.length === 0 ? {} : { servicePathWarnings: servicePath.warnings },
406
+ nativeServiceCommand,
407
+ supervisorMarker: SUPERVISOR_MARKER
408
+ };
409
+ }
410
+ function pathsFor(spec) {
411
+ const root = spec.dshHome ?? join(spec.home, ".dsh");
412
+ const stdoutFile = join(spec.logDir, "dsh-web-service.stdout.log");
413
+ const stderrFile = join(spec.logDir, "dsh-web-service.stderr.log");
414
+ if (spec.platform === "darwin") return {
415
+ stateFile: join(root, "dsh-update-status-service.json"),
416
+ definitionFile: join(spec.home, "Library", "LaunchAgents", `${spec.label}.plist`),
417
+ wrapperFile: join(root, "dsh-update-status", "dsh-web-supervisor.sh"),
418
+ stdoutFile,
419
+ stderrFile
420
+ };
421
+ if (spec.platform === "linux") return {
422
+ stateFile: join(root, "dsh-update-status-service.json"),
423
+ definitionFile: join(spec.home, ".config", "systemd", "user", "dsh-update-status-web.service"),
424
+ stdoutFile,
425
+ stderrFile
426
+ };
427
+ const base = envLocalAppData(spec);
428
+ return {
429
+ stateFile: join(root, "dsh-update-status-service.json"),
430
+ definitionFile: join(base, "dsh-update-status", "dsh-web-task.xml"),
431
+ wrapperFile: join(base, "dsh-update-status", "dsh-web-supervisor.ps1"),
432
+ stdoutFile,
433
+ stderrFile
434
+ };
435
+ }
436
+ function envLocalAppData(spec) {
437
+ return join(spec.home, "AppData", "Local");
438
+ }
439
+ function webArguments(spec) {
440
+ return [
441
+ spec.dshPath,
442
+ "--profile",
443
+ spec.profile,
444
+ "--host",
445
+ spec.host,
446
+ "--port",
447
+ String(spec.port),
448
+ "--no-open"
449
+ ];
450
+ }
451
+ function launchdWrapper(spec, paths, uid) {
452
+ const args = [spec.nodePath, ...webArguments(spec)].map(shQuote).join(" ");
453
+ const kickstart = `${shQuote(nativeServiceCommandOf(spec))} kickstart -k gui/${String(uid)}/${spec.label}`;
454
+ return `#!/bin/sh
455
+ # Managed by dsh-update-status. This wrapper, not launchd, owns the restart loop.
456
+ #
457
+ # KeepAlive alone respawns forever and has no failure ceiling: as soon as another
458
+ # launcher owns the port, every respawn dies with EADDRINUSE in a couple of
459
+ # seconds and the machine spins. The loop therefore lives here, where it can stop.
460
+ #
461
+ # * RAPID_FAILURE_WINDOW: a child that dies this fast never finished a boot.
462
+ # * RAPID_FAILURE_LIMIT: that many in a row stops the loop and says why on
463
+ # stderr (launchd captures it in the service log).
464
+ # * a child that ran longer resets the counter, so ordinary page restarts and
465
+ # long uptimes never trip it.
466
+ #
467
+ # Recover with: ${kickstart}
468
+ set -u
469
+ RAPID_FAILURE_WINDOW=${String(30)}
470
+ RAPID_FAILURE_LIMIT=${String(5)}
471
+ RESTART_DELAY=${String(3)}
472
+ child=0
473
+ forward() {
474
+ if [ "$child" -gt 0 ]; then
475
+ kill -TERM "$child" 2>/dev/null || true
476
+ wait "$child" 2>/dev/null || true
477
+ fi
478
+ exit 0
479
+ }
480
+ trap forward TERM INT HUP
481
+ rapid=0
482
+ while :; do
483
+ started=$(date +%s)
484
+ ${args} &
485
+ child=$!
486
+ wait "$child"
487
+ code=$?
488
+ child=0
489
+ elapsed=$(( $(date +%s) - started ))
490
+ if [ "$elapsed" -lt "$RAPID_FAILURE_WINDOW" ]; then
491
+ rapid=$(( rapid + 1 ))
492
+ echo "[dsh-update-status] supervisor: DSH exited with code $code after \${elapsed}s (rapid failure $rapid/$RAPID_FAILURE_LIMIT)" >&2
493
+ else
494
+ rapid=0
495
+ fi
496
+ if [ "$rapid" -ge "$RAPID_FAILURE_LIMIT" ]; then
497
+ echo "[dsh-update-status] supervisor: giving up after $RAPID_FAILURE_LIMIT rapid failures (last exit code $code). Another process may hold the port; fix that, then run: ${kickstart}" >&2
498
+ exit 78
499
+ fi
500
+ sleep "$RESTART_DELAY"
501
+ done
502
+ `;
503
+ }
504
+ function launchdDefinition(spec, paths) {
505
+ const wrapper = paths.wrapperFile;
506
+ if (wrapper === void 0) throw new Error("macOS service plan requires a supervisor wrapper path");
507
+ const args = ["/bin/sh", wrapper].map((value) => ` <string>${xml(value)}</string>`).join("\n");
508
+ const envXml = [
509
+ ["HOME", spec.home],
510
+ ["PATH", servicePathOf(spec)],
511
+ ["DSH_WEB_SUPERVISOR", spec.supervisorMarker],
512
+ ...spec.dshHome === void 0 ? [] : [["DSH_HOME", spec.dshHome]]
513
+ ].map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
514
+ return `<?xml version="1.0" encoding="UTF-8"?>
515
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
516
+ <plist version="1.0">
517
+ <dict>
518
+ <key>Label</key>
519
+ <string>${xml(spec.label)}</string>
520
+ <key>ProgramArguments</key>
521
+ <array>
522
+ ${args}
523
+ </array>
524
+ <key>WorkingDirectory</key>
525
+ <string>${xml(spec.workspace)}</string>
526
+ <key>RunAtLoad</key>
527
+ <true/>
528
+ <key>EnvironmentVariables</key>
529
+ <dict>
530
+ ${envXml}
531
+ </dict>
532
+ <key>StandardOutPath</key>
533
+ <string>${xml(paths.stdoutFile)}</string>
534
+ <key>StandardErrorPath</key>
535
+ <string>${xml(paths.stderrFile)}</string>
536
+ </dict>
537
+ </plist>
538
+ `;
539
+ }
540
+ function systemdDefinition(spec) {
541
+ const command = [spec.nodePath, ...webArguments(spec)].map((value) => `"${systemd(value)}"`).join(" ");
542
+ const env = [
543
+ `Environment="HOME=${systemd(spec.home)}"`,
544
+ `Environment="PATH=${systemd(servicePathOf(spec))}"`,
545
+ `Environment="DSH_WEB_SUPERVISOR=${spec.supervisorMarker}"`,
546
+ ...spec.dshHome === void 0 ? [] : [`Environment="DSH_HOME=${systemd(spec.dshHome)}"`]
547
+ ].join("\n");
548
+ return `[Unit]
549
+ Description=DSH Web managed by dsh-update-status
550
+ After=network-online.target
551
+ # Bound the restart loop: without this, a port held by another launcher turns
552
+ # Restart=on-failure into an endless EADDRINUSE storm. Reset after fixing the
553
+ # cause with: systemctl --user reset-failed dsh-update-status-web.service
554
+ StartLimitIntervalSec=${String(300)}
555
+ StartLimitBurst=${String(10)}
556
+
557
+ [Service]
558
+ Type=simple
559
+ WorkingDirectory="${systemd(spec.workspace)}"
560
+ ${env}
561
+ ExecStart=${command}
562
+ Restart=on-failure
563
+ RestartSec=${String(3)}
564
+
565
+ [Install]
566
+ WantedBy=default.target
567
+ `;
568
+ }
569
+ function windowsWrapper(spec, paths) {
570
+ const node = powerShell(spec.nodePath);
571
+ const dsh = powerShell(spec.dshPath);
572
+ const workspace = powerShell(spec.workspace);
573
+ const out = powerShell(paths.stdoutFile);
574
+ const err = powerShell(paths.stderrFile);
575
+ const home = powerShell(spec.home);
576
+ const dshHome = spec.dshHome === void 0 ? "" : `$env:DSH_HOME = '${powerShell(spec.dshHome)}'\n`;
577
+ return `$ErrorActionPreference = 'Stop'
578
+ $env:HOME = '${home}'
579
+ ${spec.servicePath === void 0 ? "" : `$env:PATH = '${powerShell(spec.servicePath)}'\n$env:PATHEXT = '${powerShell(spec.servicePathExt ?? ".COM;.EXE;.BAT;.CMD")}'\n`}$env:DSH_WEB_SUPERVISOR = '${spec.supervisorMarker}'
580
+ ${dshHome}Set-Location -LiteralPath '${workspace}'
581
+ $rapidWindowSeconds = ${String(30)}
582
+ $rapidLimit = ${String(5)}
583
+ $rapid = 0
584
+ while ($true) {
585
+ $started = Get-Date
586
+ & '${node}' '${dsh}' --profile '${powerShell(spec.profile)}' --host '${powerShell(spec.host)}' --port '${String(spec.port)}' --no-open 1>> '${out}' 2>> '${err}'
587
+ $exitCode = $LASTEXITCODE
588
+ $elapsed = ((Get-Date) - $started).TotalSeconds
589
+ # A child that dies within the window never finished a boot; enough of them in a
590
+ # row means another launcher owns the port, and respawning forever only spins.
591
+ if ($elapsed -lt $rapidWindowSeconds) { $rapid = $rapid + 1 } else { $rapid = 0 }
592
+ $note = "[dsh-update-status] supervisor: DSH exited with code $exitCode after $([int]$elapsed)s (rapid failure $rapid/$rapidLimit)"
593
+ Write-Host $note
594
+ # The service log is where a user looks; the console here is the task's own.
595
+ Add-Content -LiteralPath '${err}' -Value $note -ErrorAction SilentlyContinue
596
+ if ($rapid -ge $rapidLimit) {
597
+ $note = "[dsh-update-status] supervisor: giving up after $rapidLimit rapid failures (last exit code $exitCode). Another process may hold the port; fix that, then re-run the scheduled task."
598
+ Write-Host $note
599
+ Add-Content -LiteralPath '${err}' -Value $note -ErrorAction SilentlyContinue
600
+ exit 78
601
+ }
602
+ Start-Sleep -Seconds ${String(3)}
603
+ # The user service owns deliberate restarts (exit 42) and unexpected exits.
604
+ # Manual uninstall stops the scheduled task, which ends this wrapper.
605
+ }
606
+ `;
607
+ }
608
+ function windowsDefinition(spec, paths) {
609
+ const wrapper = paths.wrapperFile;
610
+ if (wrapper === void 0) throw new Error("Windows service plan requires a wrapper path");
611
+ return `<?xml version="1.0" encoding="UTF-8"?>
612
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
613
+ <RegistrationInfo><Description>DSH Web managed by dsh-update-status</Description></RegistrationInfo>
614
+ <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
615
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
616
+ <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
617
+ <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>-NoProfile -ExecutionPolicy Bypass -File &quot;${xml(wrapper)}&quot;</Arguments></Exec></Actions>
618
+ </Task>
619
+ `;
620
+ }
621
+ /** Generate a service plan without writing, starting, or stopping anything. */
622
+ function planFor(spec, uid = 0) {
623
+ const paths = pathsFor(spec);
624
+ const control = nativeServiceCommandOf(spec);
625
+ if (spec.platform === "darwin") {
626
+ const domain = `gui/${uid}`;
627
+ return {
628
+ spec,
629
+ paths,
630
+ definition: launchdDefinition(spec, paths),
631
+ wrapper: launchdWrapper(spec, paths, uid),
632
+ installCommand: [
633
+ control,
634
+ "bootstrap",
635
+ domain,
636
+ paths.definitionFile
637
+ ],
638
+ startCommand: [
639
+ control,
640
+ "kickstart",
641
+ "-k",
642
+ `${domain}/${spec.label}`
643
+ ],
644
+ recoverCommand: `${shQuote(control)} kickstart -k ${domain}/${spec.label}`,
645
+ statusCommand: [
646
+ control,
647
+ "print",
648
+ `${domain}/${spec.label}`
649
+ ],
650
+ stopCommand: [
651
+ control,
652
+ "bootout",
653
+ `${domain}/${spec.label}`
654
+ ],
655
+ uninstallCommand: [
656
+ control,
657
+ "bootout",
658
+ `${domain}/${spec.label}`
659
+ ]
660
+ };
661
+ }
662
+ if (spec.platform === "linux") return {
663
+ spec,
664
+ paths,
665
+ definition: systemdDefinition(spec),
666
+ installCommand: [
667
+ control,
668
+ "--user",
669
+ "daemon-reload"
670
+ ],
671
+ startCommand: [
672
+ control,
673
+ "--user",
674
+ "enable",
675
+ "--now",
676
+ "dsh-update-status-web.service"
677
+ ],
678
+ recoverCommand: `${shQuote(control)} --user reset-failed ${SYSTEMD_UNIT} && ${shQuote(control)} --user enable --now ${SYSTEMD_UNIT}`,
679
+ statusCommand: [
680
+ control,
681
+ "--user",
682
+ "status",
683
+ "dsh-update-status-web.service"
684
+ ],
685
+ stopCommand: [
686
+ control,
687
+ "--user",
688
+ "disable",
689
+ "--now",
690
+ "dsh-update-status-web.service"
691
+ ],
692
+ uninstallCommand: [
693
+ control,
694
+ "--user",
695
+ "disable",
696
+ "--now",
697
+ "dsh-update-status-web.service"
698
+ ]
699
+ };
700
+ return {
701
+ spec,
702
+ paths,
703
+ definition: windowsDefinition(spec, paths),
704
+ wrapper: windowsWrapper(spec, paths),
705
+ installCommand: [
706
+ control,
707
+ "/Create",
708
+ "/TN",
709
+ "DSH Update Status Web",
710
+ "/XML",
711
+ paths.definitionFile,
712
+ "/F"
713
+ ],
714
+ startCommand: [
715
+ control,
716
+ "/Run",
717
+ "/TN",
718
+ "DSH Update Status Web"
719
+ ],
720
+ recoverCommand: `"${control}" /Run /TN "DSH Update Status Web"`,
721
+ statusCommand: [
722
+ control,
723
+ "/Query",
724
+ "/TN",
725
+ "DSH Update Status Web",
726
+ "/V",
727
+ "/FO",
728
+ "LIST"
729
+ ],
730
+ stopCommand: [
731
+ control,
732
+ "/End",
733
+ "/TN",
734
+ "DSH Update Status Web"
735
+ ],
736
+ uninstallCommand: [
737
+ control,
738
+ "/Delete",
739
+ "/TN",
740
+ "DSH Update Status Web",
741
+ "/F"
742
+ ]
743
+ };
744
+ }
745
+ //#endregion
746
+ export { recordBootStamp as C, dshHomeOf as S, parseSystemdMainPid as _, planFor as a, bootStampFile as b, specFromArgs as c, servicePathOf as d, LAUNCHD_LABEL as f, parseLaunchdTrackedPid as g, WINDOWS_WRAPPER_BASENAME as h, pathsFor as i, MAX_SERVICE_PATH_LENGTH as l, WINDOWS_TASK_NAME as m, nativeServiceCommandCandidates as n, servicePlatform as o, SYSTEMD_UNIT as p, nativeServiceCommandOf as r, serviceStateFileFromArgs as s, SERVICE_LABEL as t, normalizeServicePathExt as u, parseWindowsTaskRunning as v, serviceLogDir as w, countRecentRestarts as x, RESTART_STORM_WINDOW_MS as y };