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.
@@ -1,344 +1,67 @@
1
- import { createRequire } from "node:module";
2
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
4
- import { homedir } from "node:os";
5
- import { createServer } from "node:net";
1
+ import { _ as parseSystemdMainPid, a as planFor, b as bootStampFile, c as specFromArgs, d as servicePathOf, g as parseLaunchdTrackedPid, i as pathsFor, m as WINDOWS_TASK_NAME, n as nativeServiceCommandCandidates, o as servicePlatform, p as SYSTEMD_UNIT, r as nativeServiceCommandOf, s as serviceStateFileFromArgs, t as SERVICE_LABEL, u as normalizeServicePathExt, v as parseWindowsTaskRunning, w as serviceLogDir, x as countRecentRestarts, y as RESTART_STORM_WINDOW_MS } from "./plan-D9Ybaj-3.js";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute } from "node:path";
6
4
  import { spawnSync } from "node:child_process";
7
- //#region src/service/plan.ts
8
- /** Build transparent per-OS user-service definitions; do not install them here. */
9
- const SERVICE_LABEL = "com.idoall.dsh-update-status.web";
10
- const SUPERVISOR_MARKER = "dsh-update-status";
11
- function xml(value) {
12
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13
- }
14
- function systemd(value) {
15
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
16
- }
17
- function powerShell(value) {
18
- return value.replace(/'/g, "''");
19
- }
20
- function boundedPort(value) {
21
- const port = typeof value === "number" ? value : Number(value);
22
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("`--port` must be an integer from 1 to 65535");
23
- return port;
24
- }
25
- function valueAfter(args, flag) {
26
- const index = args.indexOf(flag);
27
- if (index < 0) return void 0;
28
- const value = args[index + 1];
29
- if (value === void 0 || value.startsWith("--")) throw new Error(`missing value after ${flag}`);
30
- return value;
31
- }
32
- /**
33
- * Resolve the real JS file behind `@deepseek-ai/dsh`'s `bin.dsh`, using the
34
- * selected profile as a Node resolution anchor. This avoids platform shell
35
- * shims (`dsh.cmd`) and keeps every native service on `node <absolute-js>`.
36
- */
37
- function dshPathOf(env, profile, dshHome, override) {
38
- const fromArg = override?.trim();
39
- if (fromArg !== void 0 && fromArg !== "") return isAbsolute(fromArg) ? fromArg : resolve(env.cwd, fromArg);
40
- const home = dshHome ?? join(env.home || homedir(), ".dsh");
41
- try {
42
- const anchor = join(home, "profiles", profile, "dsh-update-status-service-resolver.cjs");
43
- const manifestPath = createRequire(anchor).resolve("@deepseek-ai/dsh/package.json");
44
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
45
- if (manifest.name !== "@deepseek-ai/dsh" || typeof manifest.bin?.dsh !== "string") throw new Error("invalid dsh package manifest");
46
- return realpathSync(join(dirname(manifestPath), manifest.bin.dsh));
47
- } catch (error) {
48
- 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)})`);
49
- }
50
- }
51
- function servicePlatform(value) {
52
- if (value === "darwin" || value === "linux" || value === "win32") return value;
53
- throw new Error(`unsupported platform: ${value}; supported: macOS, Linux, Windows`);
54
- }
55
- function specFromArgs(args, env) {
56
- const platform = servicePlatform(env.platform);
57
- const profile = valueAfter(args, "--profile") ?? env.env.DSH_PROFILE ?? "web";
58
- const workspace = valueAfter(args, "--workspace");
59
- if (workspace === void 0) throw new Error("`--workspace` is required and must be the absolute directory DSH should use");
60
- const host = valueAfter(args, "--host") ?? "127.0.0.1";
61
- const port = boundedPort(valueAfter(args, "--port") ?? 3080);
62
- const home = env.home || homedir();
63
- const dshHome = valueAfter(args, "--dsh-home") ?? (env.env.DSH_HOME?.trim() || void 0);
64
- if (dshHome !== void 0 && !isAbsolute(dshHome)) throw new Error("`--dsh-home` must be an absolute path");
65
- const logDir = valueAfter(args, "--log-dir") ?? join(dshHome ?? join(home, ".dsh"), "logs");
66
- const dshPath = dshPathOf(env, profile, dshHome, valueAfter(args, "--dsh"));
67
- const nodePath = valueAfter(args, "--node") ?? env.execPath;
68
- if (!isAbsolute(nodePath)) throw new Error("`--node` must be an absolute executable path");
69
- if (!isAbsolute(dshPath)) throw new Error("`--dsh` must be an absolute executable path");
70
- if (!isAbsolute(workspace)) throw new Error("`--workspace` must be an absolute path");
71
- if (!isAbsolute(logDir)) throw new Error("`--log-dir` must be an absolute path");
72
- return {
73
- schemaVersion: 1,
74
- platform,
75
- label: SERVICE_LABEL,
76
- nodePath,
77
- dshPath,
78
- profile,
79
- workspace,
80
- host,
81
- port,
82
- home,
83
- ...dshHome === void 0 ? {} : { dshHome },
84
- logDir,
85
- supervisorMarker: SUPERVISOR_MARKER
86
- };
87
- }
88
- function pathsFor(spec) {
89
- const root = spec.dshHome ?? join(spec.home, ".dsh");
90
- const stdoutFile = join(spec.logDir, "dsh-web-service.stdout.log");
91
- const stderrFile = join(spec.logDir, "dsh-web-service.stderr.log");
92
- if (spec.platform === "darwin") return {
93
- stateFile: join(root, "dsh-update-status-service.json"),
94
- definitionFile: join(spec.home, "Library", "LaunchAgents", `${spec.label}.plist`),
95
- stdoutFile,
96
- stderrFile
97
- };
98
- if (spec.platform === "linux") return {
99
- stateFile: join(root, "dsh-update-status-service.json"),
100
- definitionFile: join(spec.home, ".config", "systemd", "user", "dsh-update-status-web.service"),
101
- stdoutFile,
102
- stderrFile
103
- };
104
- const base = envLocalAppData(spec);
105
- return {
106
- stateFile: join(root, "dsh-update-status-service.json"),
107
- definitionFile: join(base, "dsh-update-status", "dsh-web-task.xml"),
108
- wrapperFile: join(base, "dsh-update-status", "dsh-web-supervisor.ps1"),
109
- stdoutFile,
110
- stderrFile
111
- };
112
- }
113
- function envLocalAppData(spec) {
114
- return join(spec.home, "AppData", "Local");
115
- }
116
- function webArguments(spec) {
117
- return [
118
- spec.dshPath,
119
- "--profile",
120
- spec.profile,
121
- "--host",
122
- spec.host,
123
- "--port",
124
- String(spec.port),
125
- "--no-open"
126
- ];
127
- }
128
- function launchdDefinition(spec, paths) {
129
- const args = [spec.nodePath, ...webArguments(spec)].map((value) => ` <string>${xml(value)}</string>`).join("\n");
130
- const envXml = [
131
- ["HOME", spec.home],
132
- ["PATH", `${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`],
133
- ["DSH_WEB_SUPERVISOR", spec.supervisorMarker],
134
- ...spec.dshHome === void 0 ? [] : [["DSH_HOME", spec.dshHome]]
135
- ].map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
136
- return `<?xml version="1.0" encoding="UTF-8"?>
137
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
138
- <plist version="1.0">
139
- <dict>
140
- <key>Label</key>
141
- <string>${xml(spec.label)}</string>
142
- <key>ProgramArguments</key>
143
- <array>
144
- ${args}
145
- </array>
146
- <key>WorkingDirectory</key>
147
- <string>${xml(spec.workspace)}</string>
148
- <key>RunAtLoad</key>
149
- <true/>
150
- <key>KeepAlive</key>
151
- <true/>
152
- <key>ThrottleInterval</key>
153
- <integer>3</integer>
154
- <key>EnvironmentVariables</key>
155
- <dict>
156
- ${envXml}
157
- </dict>
158
- <key>StandardOutPath</key>
159
- <string>${xml(paths.stdoutFile)}</string>
160
- <key>StandardErrorPath</key>
161
- <string>${xml(paths.stderrFile)}</string>
162
- </dict>
163
- </plist>
164
- `;
165
- }
166
- function systemdDefinition(spec) {
167
- const command = [spec.nodePath, ...webArguments(spec)].map((value) => `"${systemd(value)}"`).join(" ");
168
- const env = [
169
- `Environment="HOME=${systemd(spec.home)}"`,
170
- `Environment="PATH=${systemd(`${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin`)}"`,
171
- `Environment="DSH_WEB_SUPERVISOR=${spec.supervisorMarker}"`,
172
- ...spec.dshHome === void 0 ? [] : [`Environment="DSH_HOME=${systemd(spec.dshHome)}"`]
173
- ].join("\n");
174
- return `[Unit]
175
- Description=DSH Web managed by dsh-update-status
176
- After=network-online.target
177
-
178
- [Service]
179
- Type=simple
180
- WorkingDirectory="${systemd(spec.workspace)}"
181
- ${env}
182
- ExecStart=${command}
183
- Restart=on-failure
184
- RestartSec=3
185
-
186
- [Install]
187
- WantedBy=default.target
188
- `;
189
- }
190
- function windowsWrapper(spec, paths) {
191
- const node = powerShell(spec.nodePath);
192
- const dsh = powerShell(spec.dshPath);
193
- const workspace = powerShell(spec.workspace);
194
- const out = powerShell(paths.stdoutFile);
195
- const err = powerShell(paths.stderrFile);
196
- const home = powerShell(spec.home);
197
- const dshHome = spec.dshHome === void 0 ? "" : `$env:DSH_HOME = '${powerShell(spec.dshHome)}'\n`;
198
- return `$ErrorActionPreference = 'Stop'
199
- $env:HOME = '${home}'
200
- $env:DSH_WEB_SUPERVISOR = '${spec.supervisorMarker}'
201
- ${dshHome}Set-Location -LiteralPath '${workspace}'
202
- while ($true) {
203
- & '${node}' '${dsh}' --profile '${powerShell(spec.profile)}' --host '${powerShell(spec.host)}' --port '${String(spec.port)}' --no-open 1>> '${out}' 2>> '${err}'
204
- $exitCode = $LASTEXITCODE
205
- Start-Sleep -Seconds 3
206
- # The user service owns deliberate restarts (exit 42) and unexpected exits.
207
- # Manual uninstall stops the scheduled task, which ends this wrapper.
208
- }
209
- `;
210
- }
211
- function windowsDefinition(spec, paths) {
212
- const wrapper = paths.wrapperFile;
213
- if (wrapper === void 0) throw new Error("Windows service plan requires a wrapper path");
214
- return `<?xml version="1.0" encoding="UTF-8"?>
215
- <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
216
- <RegistrationInfo><Description>DSH Web managed by dsh-update-status</Description></RegistrationInfo>
217
- <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
218
- <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
219
- <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
220
- <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>-NoProfile -ExecutionPolicy Bypass -File &quot;${xml(wrapper)}&quot;</Arguments></Exec></Actions>
221
- </Task>
222
- `;
223
- }
224
- /** Generate a service plan without writing, starting, or stopping anything. */
225
- function planFor(spec, uid = 0) {
226
- const paths = pathsFor(spec);
5
+ import { createServer } from "node:net";
6
+ async function observeServiceState(spec, run, uid) {
227
7
  if (spec.platform === "darwin") {
228
- const domain = `gui/${uid}`;
229
- return {
230
- spec,
231
- paths,
232
- definition: launchdDefinition(spec, paths),
233
- installCommand: [
234
- "launchctl",
235
- "bootstrap",
236
- domain,
237
- paths.definitionFile
238
- ],
239
- startCommand: [
240
- "launchctl",
241
- "kickstart",
242
- "-k",
243
- `${domain}/${spec.label}`
244
- ],
245
- statusCommand: [
246
- "launchctl",
247
- "print",
248
- `${domain}/${spec.label}`
249
- ],
250
- stopCommand: [
251
- "launchctl",
252
- "bootout",
253
- `${domain}/${spec.label}`
254
- ],
255
- uninstallCommand: [
256
- "launchctl",
257
- "bootout",
258
- `${domain}/${spec.label}`
259
- ]
8
+ const result = run("launchctl", ["print", `gui/${String(uid)}/${spec.label}`]);
9
+ const pid = parseLaunchdTrackedPid(result.stdout);
10
+ return pid === void 0 ? { running: false } : {
11
+ running: true,
12
+ pid
260
13
  };
261
14
  }
262
- if (spec.platform === "linux") return {
263
- spec,
264
- paths,
265
- definition: systemdDefinition(spec),
266
- installCommand: [
267
- "systemctl",
268
- "--user",
269
- "daemon-reload"
270
- ],
271
- startCommand: [
272
- "systemctl",
273
- "--user",
274
- "enable",
275
- "--now",
276
- "dsh-update-status-web.service"
277
- ],
278
- statusCommand: [
279
- "systemctl",
280
- "--user",
281
- "status",
282
- "dsh-update-status-web.service"
283
- ],
284
- stopCommand: [
285
- "systemctl",
286
- "--user",
287
- "disable",
288
- "--now",
289
- "dsh-update-status-web.service"
290
- ],
291
- uninstallCommand: [
292
- "systemctl",
15
+ if (spec.platform === "linux") {
16
+ const result = run("systemctl", [
293
17
  "--user",
294
- "disable",
295
- "--now",
296
- "dsh-update-status-web.service"
297
- ]
298
- };
299
- return {
300
- spec,
301
- paths,
302
- definition: windowsDefinition(spec, paths),
303
- wrapper: windowsWrapper(spec, paths),
304
- installCommand: [
305
- "schtasks.exe",
306
- "/Create",
307
- "/TN",
308
- "DSH Update Status Web",
309
- "/XML",
310
- paths.definitionFile,
311
- "/F"
312
- ],
313
- startCommand: [
314
- "schtasks.exe",
315
- "/Run",
316
- "/TN",
317
- "DSH Update Status Web"
318
- ],
319
- statusCommand: [
320
- "schtasks.exe",
321
- "/Query",
322
- "/TN",
323
- "DSH Update Status Web",
324
- "/V",
325
- "/FO",
326
- "LIST"
327
- ],
328
- stopCommand: [
329
- "schtasks.exe",
330
- "/End",
331
- "/TN",
332
- "DSH Update Status Web"
333
- ],
334
- uninstallCommand: [
335
- "schtasks.exe",
336
- "/Delete",
337
- "/TN",
338
- "DSH Update Status Web",
339
- "/F"
340
- ]
341
- };
18
+ "show",
19
+ SYSTEMD_UNIT,
20
+ "-p",
21
+ "MainPID",
22
+ "--value"
23
+ ]);
24
+ const pid = parseSystemdMainPid(result.stdout);
25
+ return pid === void 0 ? { running: false } : {
26
+ running: true,
27
+ pid
28
+ };
29
+ }
30
+ const result = run("powershell.exe", [
31
+ "-NoProfile",
32
+ "-NonInteractive",
33
+ "-Command",
34
+ `(Get-ScheduledTask -TaskName '${WINDOWS_TASK_NAME}').State`
35
+ ]);
36
+ return { running: parseWindowsTaskRunning(result.stdout) };
37
+ }
38
+ function defaultSleep(ms) {
39
+ return new Promise((resolve) => {
40
+ setTimeout(resolve, ms);
41
+ });
42
+ }
43
+ async function verifyActivatedService(spec, options) {
44
+ const attempts = options.attempts ?? 4;
45
+ const intervalMs = options.intervalMs ?? 5e3;
46
+ const sleep = options.sleep ?? defaultSleep;
47
+ const uid = options.uid ?? 0;
48
+ const recentRestarts = options.recentRestarts ?? (() => countRecentRestarts(serviceLogDir(), bootStampFile(), Date.now(), 18e4));
49
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
50
+ const restarts = recentRestarts();
51
+ if (restarts >= 4) return {
52
+ kind: "crash-loop",
53
+ recentRestarts: restarts,
54
+ windowMs: RESTART_STORM_WINDOW_MS
55
+ };
56
+ if ((await observeServiceState(spec, options.run, uid)).running) {
57
+ if (attempt === attempts - 1) return { kind: "settled" };
58
+ await sleep(intervalMs);
59
+ continue;
60
+ }
61
+ if (attempt === attempts - 1) return { kind: "not-running" };
62
+ await sleep(intervalMs);
63
+ }
64
+ return { kind: "not-running" };
342
65
  }
343
66
  //#endregion
344
67
  //#region src/service/cli.ts
@@ -380,14 +103,25 @@ Options:
380
103
  --host <127.0.0.1|0.0.0.0> Bind host (default: 127.0.0.1)
381
104
  --port <1..65535> Web port (default: 3080)
382
105
  --node <path> Absolute Node executable (default: current Node)
383
- --dsh <path> Absolute dsh executable (default: resolve from PATH)
106
+ --dsh <path> Absolute dsh executable (default: resolve from profile)
384
107
  --log-dir <path> Absolute log directory (default: $DSH_HOME/logs)
108
+ --path-source <mode> Service PATH source: current (default), minimal, explicit
109
+ --service-path <value> Explicit PATH (implies --path-source explicit)
110
+ --service-pathext <v> Windows PATHEXT override (for example .COM;.EXE;.CMD)
111
+
112
+ current snapshots this command's PATH; it never executes your shell startup files.
113
+ The final PATH, source, capture time and warnings are stored in the receipt and
114
+ reused by preflight and every later service action.
385
115
 
386
116
  Workflow: plan --dry-run → install → stop the terminal-started DSH yourself → activate.
117
+
118
+ activate refuses an occupied port, never kills a process, and then verifies that
119
+ the service it started actually took over; a service that begins crash-looping
120
+ makes activate fail instead of reporting success.
387
121
  `;
388
122
  }
389
123
  function assertSafeText(value, description) {
390
- if (/[\u0000\r\n]/.test(value)) throw new Error(`${description} must not contain NUL or line breaks`);
124
+ if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) throw new Error(`${description} must not contain XML-illegal control characters or line breaks`);
391
125
  }
392
126
  function assertReadableRegular(path, description) {
393
127
  assertSafeText(path, description);
@@ -408,20 +142,73 @@ function assertExecutable(path, description) {
408
142
  throw new Error(`${description} is not executable: ${path}`);
409
143
  }
410
144
  }
411
- function validateSpec(spec) {
145
+ function envValue(env, key) {
146
+ return Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1];
147
+ }
148
+ function validateSpec(spec, level = "launch", expectedPlatform, expectedHome, expectedSystemRoot) {
149
+ if (spec.schemaVersion !== 1) throw new Error("managed service receipt has an unsupported schema version");
150
+ if (spec.platform !== "darwin" && spec.platform !== "linux" && spec.platform !== "win32") throw new Error("managed service receipt has an invalid platform");
151
+ if (expectedPlatform !== void 0 && spec.platform !== expectedPlatform) throw new Error("managed service receipt belongs to a different platform");
152
+ if (spec.label !== SERVICE_LABEL || spec.supervisorMarker !== "dsh-update-status") throw new Error("managed service receipt has an unexpected service identity");
153
+ if (expectedHome !== void 0 && spec.home !== expectedHome) throw new Error("managed service receipt belongs to a different home directory");
412
154
  for (const [description, value] of [
413
155
  ["profile", spec.profile],
414
156
  ["workspace", spec.workspace],
415
157
  ["home", spec.home],
416
158
  ["log directory", spec.logDir],
417
- ["host", spec.host]
418
- ]) assertSafeText(value, description);
159
+ ["host", spec.host],
160
+ ["Node executable", spec.nodePath],
161
+ ["dsh executable", spec.dshPath]
162
+ ]) {
163
+ if (typeof value !== "string") throw new Error(`managed service receipt has an invalid ${description}`);
164
+ assertSafeText(value, description);
165
+ }
166
+ for (const [description, value] of [
167
+ ["workspace", spec.workspace],
168
+ ["home", spec.home],
169
+ ["log directory", spec.logDir],
170
+ ["Node executable", spec.nodePath],
171
+ ["dsh executable", spec.dshPath]
172
+ ]) if (!isAbsolute(value)) throw new Error(`managed service receipt has a non-absolute ${description}`);
173
+ if (spec.dshHome !== void 0) {
174
+ if (typeof spec.dshHome !== "string") throw new Error("managed service receipt has an invalid DSH_HOME");
175
+ assertSafeText(spec.dshHome, "DSH_HOME");
176
+ if (!isAbsolute(spec.dshHome)) throw new Error("managed service receipt has a non-absolute DSH_HOME");
177
+ }
178
+ if (!Number.isInteger(spec.port) || spec.port < 1 || spec.port > 65535) throw new Error("managed service receipt has an invalid port");
179
+ if (spec.servicePath !== void 0 && typeof spec.servicePath !== "string") throw new Error("managed service receipt has an invalid service PATH");
180
+ if (spec.servicePathSource !== void 0 && spec.servicePathSource !== "current" && spec.servicePathSource !== "minimal" && spec.servicePathSource !== "explicit") throw new Error("managed service receipt has an invalid service PATH source");
181
+ if (spec.servicePathCapturedAt !== void 0 && (typeof spec.servicePathCapturedAt !== "string" || Number.isNaN(Date.parse(spec.servicePathCapturedAt)))) throw new Error("managed service receipt has an invalid service PATH capture time");
182
+ if (spec.servicePathExt !== void 0 && typeof spec.servicePathExt !== "string") throw new Error("managed service receipt has an invalid PATHEXT");
183
+ if (spec.servicePathWarnings !== void 0 && (!Array.isArray(spec.servicePathWarnings) || spec.servicePathWarnings.length > 256 || spec.servicePathWarnings.some((value) => typeof value !== "string"))) throw new Error("managed service receipt has invalid service PATH warnings");
184
+ if (spec.servicePath === void 0 && (spec.servicePathSource !== void 0 || spec.servicePathCapturedAt !== void 0 || spec.servicePathExt !== void 0 || spec.servicePathWarnings !== void 0)) throw new Error("managed service receipt has PATH metadata without a service PATH");
185
+ if (spec.servicePath !== void 0 && (spec.servicePathSource === void 0 || spec.servicePathCapturedAt === void 0)) throw new Error("managed service receipt has an incomplete service PATH record");
186
+ if (spec.platform === "win32" && spec.servicePath !== void 0 && spec.servicePathExt === void 0) throw new Error("managed Windows service receipt has no PATHEXT");
187
+ if (spec.platform !== "win32" && spec.servicePathExt !== void 0) throw new Error("managed non-Windows service receipt has PATHEXT");
188
+ if (spec.nativeServiceCommand !== void 0 && typeof spec.nativeServiceCommand !== "string") throw new Error("managed service receipt has an invalid native service command");
189
+ const servicePath = servicePathOf(spec);
190
+ assertSafeText(servicePath, "service PATH");
191
+ if (servicePath.length > 8192) throw new Error("managed service receipt has an overlong service PATH");
192
+ if (spec.servicePathExt !== void 0) {
193
+ assertSafeText(spec.servicePathExt, "PATHEXT");
194
+ if (spec.servicePathExt.length > 8192) throw new Error("managed service receipt has an overlong PATHEXT");
195
+ normalizeServicePathExt(spec.servicePathExt);
196
+ }
197
+ for (const warning of spec.servicePathWarnings ?? []) assertSafeText(warning, "service PATH warning");
198
+ const nativeCommand = nativeServiceCommandOf(spec);
199
+ assertSafeText(nativeCommand, "native service command");
200
+ if (!isAbsolute(nativeCommand)) throw new Error("managed service receipt has a non-absolute native service command");
201
+ const allowedNativeCommands = nativeServiceCommandCandidates(spec.platform, expectedSystemRoot);
202
+ const nativeKey = spec.platform === "win32" ? nativeCommand.toLowerCase() : nativeCommand;
203
+ if (!allowedNativeCommands.some((candidate) => (spec.platform === "win32" ? candidate.toLowerCase() : candidate) === nativeKey)) throw new Error("managed service receipt has an unexpected native service command");
419
204
  if (spec.host !== "127.0.0.1" && spec.host !== "0.0.0.0") throw new Error("`--host` must be 127.0.0.1 or 0.0.0.0");
420
205
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(spec.profile)) throw new Error("`--profile` must contain only letters, numbers, dot, underscore, or hyphen");
421
- assertExecutable(spec.nodePath, "Node executable");
422
- assertReadableRegular(spec.dshPath, "dsh executable");
423
- assertSafeText(spec.workspace, "workspace");
424
- if (!existsSync(spec.workspace) || !statSync(spec.workspace).isDirectory()) throw new Error(`workspace is not a directory: ${spec.workspace}`);
206
+ if (level === "launch") {
207
+ assertExecutable(spec.nodePath, "Node executable");
208
+ assertReadableRegular(spec.dshPath, "dsh executable");
209
+ assertExecutable(nativeCommand, "Native service command");
210
+ if (!existsSync(spec.workspace) || !statSync(spec.workspace).isDirectory()) throw new Error(`workspace is not a directory: ${spec.workspace}`);
211
+ }
425
212
  }
426
213
  function atomicWrite(path, content) {
427
214
  mkdirSync(dirname(path), {
@@ -435,7 +222,14 @@ function atomicWrite(path, content) {
435
222
  });
436
223
  renameSync(temporary, path);
437
224
  }
438
- function ensureManagedTarget(path, expected) {
225
+ /**
226
+ * Replace an artifact only when we can prove we wrote it: either it already holds
227
+ * exactly what we are about to write, or our own receipt names that path. The
228
+ * receipt is what makes an upgrade possible at all — an improved definition
229
+ * differs from the stored one by exactly the fix being installed, so
230
+ * byte-equality with the previous rendering can never authorise it.
231
+ */
232
+ function ensureReplaceable(path, expected, managed) {
439
233
  if (!existsSync(path)) return;
440
234
  let current;
441
235
  try {
@@ -443,13 +237,36 @@ function ensureManagedTarget(path, expected) {
443
237
  } catch {
444
238
  throw new Error(`refusing to replace unreadable existing file: ${path}`);
445
239
  }
446
- if (current !== expected) throw new Error(`refusing to overwrite an existing unmanaged service artifact: ${path}`);
240
+ if (current === expected || managed) return;
241
+ throw new Error(`refusing to overwrite an existing unmanaged service artifact: ${path}`);
242
+ }
243
+ function receiptOwnsPath(previous, path) {
244
+ if (previous === void 0) return false;
245
+ const owned = pathsFor(previous.spec);
246
+ return owned.definitionFile === path || owned.stateFile === path || owned.wrapperFile === path;
247
+ }
248
+ function receiptTextOf(receipt) {
249
+ return JSON.stringify(receipt, null, 2) + "\n";
250
+ }
251
+ function samePaths(left, right) {
252
+ return left.stateFile === right.stateFile && left.definitionFile === right.definitionFile && left.wrapperFile === right.wrapperFile && left.stdoutFile === right.stdoutFile && left.stderrFile === right.stderrFile;
253
+ }
254
+ function readReceiptIfPresent(path, env) {
255
+ return existsSync(path) ? readReceipt(path, env) : void 0;
447
256
  }
448
- function readReceipt(path) {
257
+ function readReceipt(path, env) {
449
258
  try {
450
259
  const value = JSON.parse(readFileSync(path, "utf8"));
451
260
  if (value.version !== RECEIPT_VERSION || value.managedBy !== "dsh-update-status" || value.spec === void 0 || value.paths === void 0) throw new Error("not a dsh-update-status service receipt");
452
- return value;
261
+ validateSpec(value.spec, "structural", servicePlatform(env.platform), env.home, envValue(env.env, "SYSTEMROOT"));
262
+ const canonical = pathsFor(value.spec);
263
+ if (canonical.stateFile !== path || !samePaths(value.paths, canonical)) throw new Error("managed service receipt paths do not match its service spec");
264
+ return {
265
+ version: RECEIPT_VERSION,
266
+ managedBy: "dsh-update-status",
267
+ spec: value.spec,
268
+ paths: canonical
269
+ };
453
270
  } catch (error) {
454
271
  throw new Error(`cannot read managed service receipt ${path}: ${error instanceof Error ? error.message : String(error)}`);
455
272
  }
@@ -462,6 +279,7 @@ async function portIsFree(host, port) {
462
279
  });
463
280
  }
464
281
  function serialisePlan(plan) {
282
+ const inheritedWindowsPath = plan.spec.platform === "win32" && plan.spec.servicePath === void 0;
465
283
  return JSON.stringify({
466
284
  platform: plan.spec.platform,
467
285
  label: plan.spec.label,
@@ -471,6 +289,14 @@ function serialisePlan(plan) {
471
289
  workspace: plan.spec.workspace,
472
290
  host: plan.spec.host,
473
291
  port: plan.spec.port,
292
+ nativeServiceCommand: nativeServiceCommandOf(plan.spec),
293
+ servicePath: {
294
+ source: inheritedWindowsPath ? "inherited logon environment (legacy receipt)" : plan.spec.servicePathSource ?? "minimal (legacy receipt)",
295
+ value: inheritedWindowsPath ? null : servicePathOf(plan.spec),
296
+ ...plan.spec.servicePathCapturedAt === void 0 ? {} : { capturedAt: plan.spec.servicePathCapturedAt },
297
+ ...plan.spec.servicePathExt === void 0 ? {} : { pathExt: plan.spec.servicePathExt },
298
+ warnings: plan.spec.servicePathWarnings ?? []
299
+ },
474
300
  definitionFile: plan.paths.definitionFile,
475
301
  wrapperFile: plan.paths.wrapperFile,
476
302
  stateFile: plan.paths.stateFile,
@@ -483,42 +309,14 @@ function serialisePlan(plan) {
483
309
  start: plan.startCommand,
484
310
  status: plan.statusCommand,
485
311
  stop: plan.stopCommand,
486
- uninstall: plan.uninstallCommand
312
+ uninstall: plan.uninstallCommand,
313
+ recover: plan.recoverCommand
487
314
  }
488
315
  }, null, 2) + "\n";
489
316
  }
490
- function commandsForActivate(plan, uid) {
491
- if (plan.spec.platform === "darwin") return [[
492
- "launchctl",
493
- "bootstrap",
494
- `gui/${uid}`,
495
- plan.paths.definitionFile
496
- ]];
497
- if (plan.spec.platform === "linux") return [[
498
- "systemctl",
499
- "--user",
500
- "daemon-reload"
501
- ], [
502
- "systemctl",
503
- "--user",
504
- "enable",
505
- "--now",
506
- "dsh-update-status-web.service"
507
- ]];
508
- return [[
509
- "schtasks.exe",
510
- "/Create",
511
- "/TN",
512
- "DSH Update Status Web",
513
- "/XML",
514
- plan.paths.definitionFile,
515
- "/F"
516
- ], [
517
- "schtasks.exe",
518
- "/Run",
519
- "/TN",
520
- "DSH Update Status Web"
521
- ]];
317
+ function commandsForActivate(plan, _uid) {
318
+ if (plan.spec.platform === "darwin") return [plan.installCommand];
319
+ return [plan.installCommand, plan.startCommand];
522
320
  }
523
321
  function invoke(command, run) {
524
322
  const [file, ...args] = command;
@@ -540,7 +338,8 @@ function preflight(spec) {
540
338
  env: {
541
339
  HOME: spec.home,
542
340
  ...spec.dshHome === void 0 ? {} : { DSH_HOME: spec.dshHome },
543
- PATH: `${dirname(spec.nodePath)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
341
+ PATH: servicePathOf(spec),
342
+ ...spec.platform === "win32" && spec.servicePathExt !== void 0 ? { PATHEXT: spec.servicePathExt } : {}
544
343
  }
545
344
  });
546
345
  if (result.status !== 0) {
@@ -548,6 +347,32 @@ function preflight(spec) {
548
347
  throw new Error(`DSH boot-free preflight failed; no service definition was written: ${diagnostic}`);
549
348
  }
550
349
  }
350
+ const VALUE_OPTIONS = /* @__PURE__ */ new Set([
351
+ "--profile",
352
+ "--workspace",
353
+ "--dsh-home",
354
+ "--host",
355
+ "--port",
356
+ "--node",
357
+ "--dsh",
358
+ "--log-dir",
359
+ "--path-source",
360
+ "--service-path",
361
+ "--service-pathext"
362
+ ]);
363
+ function assertKnownOptions(args) {
364
+ for (let index = 0; index < args.length; index += 1) {
365
+ const value = args[index];
366
+ if (value === "--dry-run") continue;
367
+ if (!VALUE_OPTIONS.has(value)) throw new Error(`unknown option: ${value}`);
368
+ const next = args[index + 1];
369
+ if (next === void 0 || next.startsWith("--")) throw new Error(`missing value after ${value}`);
370
+ index += 1;
371
+ }
372
+ }
373
+ function warnAboutPath(spec) {
374
+ for (const warning of spec.servicePathWarnings ?? []) process.stderr.write(`Warning: ${warning}\n`);
375
+ }
551
376
  async function main(argv = process.argv.slice(2), env = environment(), run = runner, hooks = {}) {
552
377
  const [action, ...rawArgs] = argv;
553
378
  if (action === void 0 || action === "--help" || action === "-h") {
@@ -565,28 +390,36 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
565
390
  process.stderr.write(`Unknown command: ${action}\n${usage()}`);
566
391
  return 2;
567
392
  }
393
+ try {
394
+ assertKnownOptions(rawArgs);
395
+ } catch (error) {
396
+ process.stderr.write(`dsh-update-status-service: ${error instanceof Error ? error.message : String(error)}\n`);
397
+ return 2;
398
+ }
568
399
  const dryRun = rawArgs.includes("--dry-run") || action === "plan";
569
400
  const args = rawArgs.filter((value) => value !== "--dry-run");
570
401
  try {
571
- const spec = specFromArgs(args, env);
572
- validateSpec(spec);
573
- const plan = planFor(spec, env.uid ?? 0);
574
- if (dryRun) {
575
- process.stdout.write(serialisePlan(plan));
576
- return 0;
577
- }
578
- if (action === "install") {
402
+ if (dryRun || action === "install") {
403
+ const spec = specFromArgs(args, env);
404
+ validateSpec(spec, "launch", servicePlatform(env.platform), env.home, envValue(env.env, "SYSTEMROOT"));
405
+ const plan = planFor(spec, env.uid ?? 0);
406
+ if (dryRun) {
407
+ process.stdout.write(serialisePlan(plan));
408
+ return 0;
409
+ }
410
+ process.stdout.write(`Service PATH (${spec.servicePathSource ?? "minimal"}): ${servicePathOf(spec)}\n`);
411
+ warnAboutPath(spec);
579
412
  (hooks.preflight ?? preflight)(spec);
580
- ensureManagedTarget(plan.paths.definitionFile, plan.definition);
581
- if (plan.wrapper !== void 0 && plan.paths.wrapperFile !== void 0) ensureManagedTarget(plan.paths.wrapperFile, plan.wrapper);
582
- const receipt = {
413
+ const previous = readReceiptIfPresent(plan.paths.stateFile, env);
414
+ ensureReplaceable(plan.paths.definitionFile, plan.definition, receiptOwnsPath(previous, plan.paths.definitionFile));
415
+ if (plan.wrapper !== void 0 && plan.paths.wrapperFile !== void 0) ensureReplaceable(plan.paths.wrapperFile, plan.wrapper, receiptOwnsPath(previous, plan.paths.wrapperFile));
416
+ const receiptText = receiptTextOf({
583
417
  version: RECEIPT_VERSION,
584
418
  managedBy: "dsh-update-status",
585
419
  spec,
586
420
  paths: plan.paths
587
- };
588
- const receiptText = JSON.stringify(receipt, null, 2) + "\n";
589
- ensureManagedTarget(plan.paths.stateFile, receiptText);
421
+ });
422
+ ensureReplaceable(plan.paths.stateFile, receiptText, receiptOwnsPath(previous, plan.paths.stateFile));
590
423
  mkdirSync(spec.logDir, {
591
424
  recursive: true,
592
425
  mode: 448
@@ -597,11 +430,19 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
597
430
  process.stdout.write(`Staged user service definition: ${plan.paths.definitionFile}\nNo service has started. Stop your terminal-started DSH, then run activate.\n`);
598
431
  return 0;
599
432
  }
600
- const receipt = readReceipt(plan.paths.stateFile);
433
+ const receipt = readReceipt(serviceStateFileFromArgs(args, env), env);
434
+ const plan = planFor(receipt.spec, env.uid ?? 0);
435
+ if (action === "activate") validateSpec(receipt.spec, "launch", servicePlatform(env.platform), env.home, envValue(env.env, "SYSTEMROOT"));
601
436
  if (action === "activate") {
602
437
  if (!await portIsFree(receipt.spec.host, receipt.spec.port)) throw new Error(`port ${receipt.spec.host}:${String(receipt.spec.port)} is in use; stop the existing DSH yourself before activate. Nothing was killed.`);
603
438
  for (const command of commandsForActivate(plan, env.uid ?? 0)) invoke(command, run);
604
- process.stdout.write("User service activated. Wait for DSH to become ready, then open the existing Web URL.\n");
439
+ const verification = await (hooks.verify ?? ((installed) => verifyActivatedService(installed, {
440
+ run,
441
+ uid: env.uid ?? 0
442
+ })))(receipt.spec);
443
+ if (verification.kind === "crash-loop") throw new Error(`the activated service is restarting repeatedly (${String(verification.recentRestarts)} starts within ${String(Math.round(verification.windowMs / 1e3))}s); another process probably holds ${receipt.spec.host}:${String(receipt.spec.port)}. Nothing was killed. Stop that instance, fix the cause, then run activate again.`);
444
+ if (verification.kind === "not-running") throw new Error(`the activated service reported no running process; check the native status and the logs in ${receipt.spec.logDir}. Nothing was killed.`);
445
+ process.stdout.write("User service activated and verified to be running. Wait for DSH to become ready, then open the existing Web URL.\n");
605
446
  return 0;
606
447
  }
607
448
  if (action === "status") {
@@ -617,9 +458,9 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
617
458
  }
618
459
  const result = run(plan.uninstallCommand[0], plan.uninstallCommand.slice(1));
619
460
  if (result.status !== 0) process.stderr.write(`Warning: ${result.stderr || result.stdout || "service was not loaded"}\n`);
620
- rmSync(receipt.paths.definitionFile, { force: true });
621
- if (receipt.paths.wrapperFile !== void 0) rmSync(receipt.paths.wrapperFile, { force: true });
622
- rmSync(receipt.paths.stateFile, { force: true });
461
+ rmSync(plan.paths.definitionFile, { force: true });
462
+ if (plan.paths.wrapperFile !== void 0) rmSync(plan.paths.wrapperFile, { force: true });
463
+ rmSync(plan.paths.stateFile, { force: true });
623
464
  process.stdout.write("Removed the managed user-service definition. Logs were preserved.\n");
624
465
  return 0;
625
466
  } catch (error) {
@@ -631,4 +472,4 @@ if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) main().the
631
472
  process.exitCode = code;
632
473
  });
633
474
  //#endregion
634
- export { main };
475
+ export { main, serialisePlan };