dsh-update-status 0.3.2 → 0.4.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.
@@ -0,0 +1,587 @@
1
+ import { createRequire } from "node:module";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } 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/plan.ts
169
+ /** Build transparent per-OS user-service definitions; do not install them here. */
170
+ const SERVICE_LABEL = LAUNCHD_LABEL;
171
+ const SUPERVISOR_MARKER = "dsh-update-status";
172
+ function xml(value) {
173
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
174
+ }
175
+ function systemd(value) {
176
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
177
+ }
178
+ function powerShell(value) {
179
+ return value.replace(/'/g, "''");
180
+ }
181
+ function shQuote(value) {
182
+ return `'${value.replace(/'/g, "'\\''")}'`;
183
+ }
184
+ function boundedPort(value) {
185
+ const port = typeof value === "number" ? value : Number(value);
186
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("`--port` must be an integer from 1 to 65535");
187
+ return port;
188
+ }
189
+ function valueAfter(args, flag) {
190
+ const index = args.indexOf(flag);
191
+ if (index < 0) return void 0;
192
+ const value = args[index + 1];
193
+ if (value === void 0 || value.startsWith("--")) throw new Error(`missing value after ${flag}`);
194
+ return value;
195
+ }
196
+ /**
197
+ * Resolve the real JS file behind `@deepseek-ai/dsh`'s `bin.dsh`, using the
198
+ * selected profile as a Node resolution anchor. This avoids platform shell
199
+ * shims (`dsh.cmd`) and keeps every native service on `node <absolute-js>`.
200
+ */
201
+ function dshPathOf(env, profile, dshHome, override) {
202
+ const fromArg = override?.trim();
203
+ if (fromArg !== void 0 && fromArg !== "") return isAbsolute(fromArg) ? fromArg : resolve(env.cwd, fromArg);
204
+ const home = dshHome ?? join(env.home || homedir(), ".dsh");
205
+ try {
206
+ const anchor = join(home, "profiles", profile, "dsh-update-status-service-resolver.cjs");
207
+ const manifestPath = createRequire(anchor).resolve("@deepseek-ai/dsh/package.json");
208
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
209
+ if (manifest.name !== "@deepseek-ai/dsh" || typeof manifest.bin?.dsh !== "string") throw new Error("invalid dsh package manifest");
210
+ return realpathSync(join(dirname(manifestPath), manifest.bin.dsh));
211
+ } catch (error) {
212
+ 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)})`);
213
+ }
214
+ }
215
+ function servicePlatform(value) {
216
+ if (value === "darwin" || value === "linux" || value === "win32") return value;
217
+ throw new Error(`unsupported platform: ${value}; supported: macOS, Linux, Windows`);
218
+ }
219
+ function specFromArgs(args, env) {
220
+ const platform = servicePlatform(env.platform);
221
+ const profile = valueAfter(args, "--profile") ?? env.env.DSH_PROFILE ?? "web";
222
+ const workspace = valueAfter(args, "--workspace");
223
+ if (workspace === void 0) throw new Error("`--workspace` is required and must be the absolute directory DSH should use");
224
+ const host = valueAfter(args, "--host") ?? "127.0.0.1";
225
+ const port = boundedPort(valueAfter(args, "--port") ?? 3080);
226
+ const home = env.home || homedir();
227
+ const dshHome = valueAfter(args, "--dsh-home") ?? (env.env.DSH_HOME?.trim() || void 0);
228
+ if (dshHome !== void 0 && !isAbsolute(dshHome)) throw new Error("`--dsh-home` must be an absolute path");
229
+ const logDir = valueAfter(args, "--log-dir") ?? join(dshHome ?? join(home, ".dsh"), "logs");
230
+ const dshPath = dshPathOf(env, profile, dshHome, valueAfter(args, "--dsh"));
231
+ const nodePath = valueAfter(args, "--node") ?? env.execPath;
232
+ if (!isAbsolute(nodePath)) throw new Error("`--node` must be an absolute executable path");
233
+ if (!isAbsolute(dshPath)) throw new Error("`--dsh` must be an absolute executable path");
234
+ if (!isAbsolute(workspace)) throw new Error("`--workspace` must be an absolute path");
235
+ if (!isAbsolute(logDir)) throw new Error("`--log-dir` must be an absolute path");
236
+ return {
237
+ schemaVersion: 1,
238
+ platform,
239
+ label: SERVICE_LABEL,
240
+ nodePath,
241
+ dshPath,
242
+ profile,
243
+ workspace,
244
+ host,
245
+ port,
246
+ home,
247
+ ...dshHome === void 0 ? {} : { dshHome },
248
+ logDir,
249
+ supervisorMarker: SUPERVISOR_MARKER
250
+ };
251
+ }
252
+ function pathsFor(spec) {
253
+ const root = spec.dshHome ?? join(spec.home, ".dsh");
254
+ const stdoutFile = join(spec.logDir, "dsh-web-service.stdout.log");
255
+ const stderrFile = join(spec.logDir, "dsh-web-service.stderr.log");
256
+ if (spec.platform === "darwin") return {
257
+ stateFile: join(root, "dsh-update-status-service.json"),
258
+ definitionFile: join(spec.home, "Library", "LaunchAgents", `${spec.label}.plist`),
259
+ wrapperFile: join(root, "dsh-update-status", "dsh-web-supervisor.sh"),
260
+ stdoutFile,
261
+ stderrFile
262
+ };
263
+ if (spec.platform === "linux") return {
264
+ stateFile: join(root, "dsh-update-status-service.json"),
265
+ definitionFile: join(spec.home, ".config", "systemd", "user", "dsh-update-status-web.service"),
266
+ stdoutFile,
267
+ stderrFile
268
+ };
269
+ const base = envLocalAppData(spec);
270
+ return {
271
+ stateFile: join(root, "dsh-update-status-service.json"),
272
+ definitionFile: join(base, "dsh-update-status", "dsh-web-task.xml"),
273
+ wrapperFile: join(base, "dsh-update-status", "dsh-web-supervisor.ps1"),
274
+ stdoutFile,
275
+ stderrFile
276
+ };
277
+ }
278
+ function envLocalAppData(spec) {
279
+ return join(spec.home, "AppData", "Local");
280
+ }
281
+ function webArguments(spec) {
282
+ return [
283
+ spec.dshPath,
284
+ "--profile",
285
+ spec.profile,
286
+ "--host",
287
+ spec.host,
288
+ "--port",
289
+ String(spec.port),
290
+ "--no-open"
291
+ ];
292
+ }
293
+ function launchdWrapper(spec, paths, uid) {
294
+ const args = [spec.nodePath, ...webArguments(spec)].map(shQuote).join(" ");
295
+ const kickstart = `launchctl kickstart -k gui/${String(uid)}/${spec.label}`;
296
+ return `#!/bin/sh
297
+ # Managed by dsh-update-status. This wrapper, not launchd, owns the restart loop.
298
+ #
299
+ # KeepAlive alone respawns forever and has no failure ceiling: as soon as another
300
+ # launcher owns the port, every respawn dies with EADDRINUSE in a couple of
301
+ # seconds and the machine spins. The loop therefore lives here, where it can stop.
302
+ #
303
+ # * RAPID_FAILURE_WINDOW: a child that dies this fast never finished a boot.
304
+ # * RAPID_FAILURE_LIMIT: that many in a row stops the loop and says why on
305
+ # stderr (launchd captures it in the service log).
306
+ # * a child that ran longer resets the counter, so ordinary page restarts and
307
+ # long uptimes never trip it.
308
+ #
309
+ # Recover with: ${kickstart}
310
+ set -u
311
+ RAPID_FAILURE_WINDOW=${String(30)}
312
+ RAPID_FAILURE_LIMIT=${String(5)}
313
+ RESTART_DELAY=${String(3)}
314
+ child=0
315
+ forward() {
316
+ if [ "$child" -gt 0 ]; then
317
+ kill -TERM "$child" 2>/dev/null || true
318
+ wait "$child" 2>/dev/null || true
319
+ fi
320
+ exit 0
321
+ }
322
+ trap forward TERM INT HUP
323
+ rapid=0
324
+ while :; do
325
+ started=$(date +%s)
326
+ ${args} &
327
+ child=$!
328
+ wait "$child"
329
+ code=$?
330
+ child=0
331
+ elapsed=$(( $(date +%s) - started ))
332
+ if [ "$elapsed" -lt "$RAPID_FAILURE_WINDOW" ]; then
333
+ rapid=$(( rapid + 1 ))
334
+ echo "[dsh-update-status] supervisor: DSH exited with code $code after \${elapsed}s (rapid failure $rapid/$RAPID_FAILURE_LIMIT)" >&2
335
+ else
336
+ rapid=0
337
+ fi
338
+ if [ "$rapid" -ge "$RAPID_FAILURE_LIMIT" ]; then
339
+ 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
340
+ exit 78
341
+ fi
342
+ sleep "$RESTART_DELAY"
343
+ done
344
+ `;
345
+ }
346
+ function launchdDefinition(spec, paths) {
347
+ const wrapper = paths.wrapperFile;
348
+ if (wrapper === void 0) throw new Error("macOS service plan requires a supervisor wrapper path");
349
+ const args = ["/bin/sh", wrapper].map((value) => ` <string>${xml(value)}</string>`).join("\n");
350
+ const envXml = [
351
+ ["HOME", spec.home],
352
+ ["PATH", `${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`],
353
+ ["DSH_WEB_SUPERVISOR", spec.supervisorMarker],
354
+ ...spec.dshHome === void 0 ? [] : [["DSH_HOME", spec.dshHome]]
355
+ ].map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
356
+ return `<?xml version="1.0" encoding="UTF-8"?>
357
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
358
+ <plist version="1.0">
359
+ <dict>
360
+ <key>Label</key>
361
+ <string>${xml(spec.label)}</string>
362
+ <key>ProgramArguments</key>
363
+ <array>
364
+ ${args}
365
+ </array>
366
+ <key>WorkingDirectory</key>
367
+ <string>${xml(spec.workspace)}</string>
368
+ <key>RunAtLoad</key>
369
+ <true/>
370
+ <key>EnvironmentVariables</key>
371
+ <dict>
372
+ ${envXml}
373
+ </dict>
374
+ <key>StandardOutPath</key>
375
+ <string>${xml(paths.stdoutFile)}</string>
376
+ <key>StandardErrorPath</key>
377
+ <string>${xml(paths.stderrFile)}</string>
378
+ </dict>
379
+ </plist>
380
+ `;
381
+ }
382
+ function systemdDefinition(spec) {
383
+ const command = [spec.nodePath, ...webArguments(spec)].map((value) => `"${systemd(value)}"`).join(" ");
384
+ const env = [
385
+ `Environment="HOME=${systemd(spec.home)}"`,
386
+ `Environment="PATH=${systemd(`${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin`)}"`,
387
+ `Environment="DSH_WEB_SUPERVISOR=${spec.supervisorMarker}"`,
388
+ ...spec.dshHome === void 0 ? [] : [`Environment="DSH_HOME=${systemd(spec.dshHome)}"`]
389
+ ].join("\n");
390
+ return `[Unit]
391
+ Description=DSH Web managed by dsh-update-status
392
+ After=network-online.target
393
+ # Bound the restart loop: without this, a port held by another launcher turns
394
+ # Restart=on-failure into an endless EADDRINUSE storm. Reset after fixing the
395
+ # cause with: systemctl --user reset-failed dsh-update-status-web.service
396
+ StartLimitIntervalSec=${String(300)}
397
+ StartLimitBurst=${String(10)}
398
+
399
+ [Service]
400
+ Type=simple
401
+ WorkingDirectory="${systemd(spec.workspace)}"
402
+ ${env}
403
+ ExecStart=${command}
404
+ Restart=on-failure
405
+ RestartSec=${String(3)}
406
+
407
+ [Install]
408
+ WantedBy=default.target
409
+ `;
410
+ }
411
+ function windowsWrapper(spec, paths) {
412
+ const node = powerShell(spec.nodePath);
413
+ const dsh = powerShell(spec.dshPath);
414
+ const workspace = powerShell(spec.workspace);
415
+ const out = powerShell(paths.stdoutFile);
416
+ const err = powerShell(paths.stderrFile);
417
+ const home = powerShell(spec.home);
418
+ const dshHome = spec.dshHome === void 0 ? "" : `$env:DSH_HOME = '${powerShell(spec.dshHome)}'\n`;
419
+ return `$ErrorActionPreference = 'Stop'
420
+ $env:HOME = '${home}'
421
+ $env:DSH_WEB_SUPERVISOR = '${spec.supervisorMarker}'
422
+ ${dshHome}Set-Location -LiteralPath '${workspace}'
423
+ $rapidWindowSeconds = ${String(30)}
424
+ $rapidLimit = ${String(5)}
425
+ $rapid = 0
426
+ while ($true) {
427
+ $started = Get-Date
428
+ & '${node}' '${dsh}' --profile '${powerShell(spec.profile)}' --host '${powerShell(spec.host)}' --port '${String(spec.port)}' --no-open 1>> '${out}' 2>> '${err}'
429
+ $exitCode = $LASTEXITCODE
430
+ $elapsed = ((Get-Date) - $started).TotalSeconds
431
+ # A child that dies within the window never finished a boot; enough of them in a
432
+ # row means another launcher owns the port, and respawning forever only spins.
433
+ if ($elapsed -lt $rapidWindowSeconds) { $rapid = $rapid + 1 } else { $rapid = 0 }
434
+ $note = "[dsh-update-status] supervisor: DSH exited with code $exitCode after $([int]$elapsed)s (rapid failure $rapid/$rapidLimit)"
435
+ Write-Host $note
436
+ # The service log is where a user looks; the console here is the task's own.
437
+ Add-Content -LiteralPath '${err}' -Value $note -ErrorAction SilentlyContinue
438
+ if ($rapid -ge $rapidLimit) {
439
+ $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."
440
+ Write-Host $note
441
+ Add-Content -LiteralPath '${err}' -Value $note -ErrorAction SilentlyContinue
442
+ exit 78
443
+ }
444
+ Start-Sleep -Seconds ${String(3)}
445
+ # The user service owns deliberate restarts (exit 42) and unexpected exits.
446
+ # Manual uninstall stops the scheduled task, which ends this wrapper.
447
+ }
448
+ `;
449
+ }
450
+ function windowsDefinition(spec, paths) {
451
+ const wrapper = paths.wrapperFile;
452
+ if (wrapper === void 0) throw new Error("Windows service plan requires a wrapper path");
453
+ return `<?xml version="1.0" encoding="UTF-8"?>
454
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
455
+ <RegistrationInfo><Description>DSH Web managed by dsh-update-status</Description></RegistrationInfo>
456
+ <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
457
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
458
+ <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
459
+ <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>-NoProfile -ExecutionPolicy Bypass -File &quot;${xml(wrapper)}&quot;</Arguments></Exec></Actions>
460
+ </Task>
461
+ `;
462
+ }
463
+ /** Generate a service plan without writing, starting, or stopping anything. */
464
+ function planFor(spec, uid = 0) {
465
+ const paths = pathsFor(spec);
466
+ if (spec.platform === "darwin") {
467
+ const domain = `gui/${uid}`;
468
+ return {
469
+ spec,
470
+ paths,
471
+ definition: launchdDefinition(spec, paths),
472
+ wrapper: launchdWrapper(spec, paths, uid),
473
+ installCommand: [
474
+ "launchctl",
475
+ "bootstrap",
476
+ domain,
477
+ paths.definitionFile
478
+ ],
479
+ startCommand: [
480
+ "launchctl",
481
+ "kickstart",
482
+ "-k",
483
+ `${domain}/${spec.label}`
484
+ ],
485
+ recoverCommand: `launchctl kickstart -k ${domain}/${spec.label}`,
486
+ statusCommand: [
487
+ "launchctl",
488
+ "print",
489
+ `${domain}/${spec.label}`
490
+ ],
491
+ stopCommand: [
492
+ "launchctl",
493
+ "bootout",
494
+ `${domain}/${spec.label}`
495
+ ],
496
+ uninstallCommand: [
497
+ "launchctl",
498
+ "bootout",
499
+ `${domain}/${spec.label}`
500
+ ]
501
+ };
502
+ }
503
+ if (spec.platform === "linux") return {
504
+ spec,
505
+ paths,
506
+ definition: systemdDefinition(spec),
507
+ installCommand: [
508
+ "systemctl",
509
+ "--user",
510
+ "daemon-reload"
511
+ ],
512
+ startCommand: [
513
+ "systemctl",
514
+ "--user",
515
+ "enable",
516
+ "--now",
517
+ "dsh-update-status-web.service"
518
+ ],
519
+ recoverCommand: `systemctl --user reset-failed ${SYSTEMD_UNIT} && systemctl --user enable --now ${SYSTEMD_UNIT}`,
520
+ statusCommand: [
521
+ "systemctl",
522
+ "--user",
523
+ "status",
524
+ "dsh-update-status-web.service"
525
+ ],
526
+ stopCommand: [
527
+ "systemctl",
528
+ "--user",
529
+ "disable",
530
+ "--now",
531
+ "dsh-update-status-web.service"
532
+ ],
533
+ uninstallCommand: [
534
+ "systemctl",
535
+ "--user",
536
+ "disable",
537
+ "--now",
538
+ "dsh-update-status-web.service"
539
+ ]
540
+ };
541
+ return {
542
+ spec,
543
+ paths,
544
+ definition: windowsDefinition(spec, paths),
545
+ wrapper: windowsWrapper(spec, paths),
546
+ installCommand: [
547
+ "schtasks.exe",
548
+ "/Create",
549
+ "/TN",
550
+ "DSH Update Status Web",
551
+ "/XML",
552
+ paths.definitionFile,
553
+ "/F"
554
+ ],
555
+ startCommand: [
556
+ "schtasks.exe",
557
+ "/Run",
558
+ "/TN",
559
+ "DSH Update Status Web"
560
+ ],
561
+ recoverCommand: "schtasks.exe /Run /TN \"DSH Update Status Web\"",
562
+ statusCommand: [
563
+ "schtasks.exe",
564
+ "/Query",
565
+ "/TN",
566
+ "DSH Update Status Web",
567
+ "/V",
568
+ "/FO",
569
+ "LIST"
570
+ ],
571
+ stopCommand: [
572
+ "schtasks.exe",
573
+ "/End",
574
+ "/TN",
575
+ "DSH Update Status Web"
576
+ ],
577
+ uninstallCommand: [
578
+ "schtasks.exe",
579
+ "/Delete",
580
+ "/TN",
581
+ "DSH Update Status Web",
582
+ "/F"
583
+ ]
584
+ };
585
+ }
586
+ //#endregion
587
+ export { WINDOWS_TASK_NAME as a, parseSystemdMainPid as c, bootStampFile as d, countRecentRestarts as f, serviceLogDir as h, SYSTEMD_UNIT as i, parseWindowsTaskRunning as l, recordBootStamp as m, specFromArgs as n, WINDOWS_WRAPPER_BASENAME as o, dshHomeOf as p, LAUNCHD_LABEL as r, parseLaunchdTrackedPid as s, planFor as t, RESTART_STORM_WINDOW_MS as u };
@@ -0,0 +1,58 @@
1
+ //#region src/service/types.d.ts
2
+ /** Cross-platform, user-owned DSH Web supervision contracts. */
3
+ type ServicePlatform = 'darwin' | 'linux' | 'win32';
4
+ interface ServiceSpec {
5
+ readonly schemaVersion: 1;
6
+ readonly platform: ServicePlatform;
7
+ readonly label: string;
8
+ /** Absolute Node executable used to launch the DSH CLI. */
9
+ readonly nodePath: string;
10
+ /** Absolute DSH CLI executable (the file behind the `dsh` command). */
11
+ readonly dshPath: string;
12
+ readonly profile: string;
13
+ readonly workspace: string;
14
+ readonly host: string;
15
+ readonly port: number;
16
+ readonly home: string;
17
+ readonly dshHome?: string;
18
+ readonly logDir: string;
19
+ /** Written by the user-service definition, never inferred from a TTY. */
20
+ readonly supervisorMarker: 'dsh-update-status';
21
+ }
22
+ interface CliEnvironment {
23
+ readonly platform: NodeJS.Platform;
24
+ readonly home: string;
25
+ /** Numeric uid needed by launchctl's `gui/<uid>` domain. */
26
+ readonly uid?: number;
27
+ readonly cwd: string;
28
+ readonly execPath: string;
29
+ readonly env: Readonly<Record<string, string | undefined>>;
30
+ readonly path?: string;
31
+ }
32
+ interface CommandResult {
33
+ readonly status: number | null;
34
+ readonly stdout: string;
35
+ readonly stderr: string;
36
+ readonly error?: string;
37
+ }
38
+ type CommandRunner = (file: string, args: readonly string[]) => CommandResult;
39
+ //#endregion
40
+ //#region src/service/observe.d.ts
41
+ type ServiceVerification = {
42
+ readonly kind: 'settled';
43
+ } | {
44
+ readonly kind: 'crash-loop';
45
+ readonly recentRestarts: number;
46
+ readonly windowMs: number;
47
+ } | {
48
+ readonly kind: 'not-running';
49
+ };
50
+ //#endregion
51
+ //#region src/service/cli.d.ts
52
+ export interface CliHooks {
53
+ readonly preflight?: (spec: ServiceSpec) => void;
54
+ /** Injected in tests; defaults to the real post-activation verification. */
55
+ readonly verify?: (spec: ServiceSpec) => Promise<ServiceVerification>;
56
+ }
57
+ export declare function main(argv?: readonly string[], env?: CliEnvironment, run?: CommandRunner, hooks?: CliHooks): Promise<number>;
58
+ //#endregion