dsh-update-status 0.4.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.4.1 — 2026-10-09
6
+
7
+ Verified DeepSeek Harness: `0.2.1-alpha.1` (the current release, and the one this profile runs), also `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1` and `0.1.7-alpha.2`. Release notes: [`docs/releases/v0.4.1.md`](docs/releases/v0.4.1.md).
8
+
9
+ - **The service definition now uses one audited, cross-platform PATH instead of package-manager guesses.** `install` snapshots its current PATH by default, puts the selected Node directory first, removes empty/relative/duplicate entries, keeps missing absolute directories with warnings, and persists the final value, source, capture time, PATHEXT (Windows) and warnings in the receipt. `--path-source minimal` preserves a fixed deployment, while `--service-path` supplies an explicit value. macOS launchd, Linux systemd and the Windows wrapper consume that same persisted result; preflight uses it too, and later actions locate the receipt from DSH_HOME instead of recapturing a different terminal environment. `plan --dry-run` prints the exact PATH and warnings. Old receipts retain their historical behaviour (macOS/Linux minimal PATH; Windows Task Scheduler login environment). Tests cover POSIX/Windows separators, drive/UNC/Unicode/quoted paths, PATHEXT, systemd percent escaping, warnings, length limits, native command discovery and lifecycle persistence; only macOS receives native service verification in this environment.
10
+ - **Receipt-backed lifecycle operations are safer and independent of the invoking terminal.** The receipt persists an absolute launchctl/systemctl/schtasks path; later actions never search the caller's PATH. Status, stop and uninstall need only DSH_HOME and continue to work after the old Node/DSH/workspace was moved or deleted, while activate still requires usable launch files. Stored artifact paths must exactly match paths re-derived from the fixed service identity, platform and current home; uninstall deletes only those canonical paths, so a modified receipt cannot nominate an arbitrary file. Unknown options are rejected, XML-illegal control characters and overlong PATH/PATHEXT values are refused, and legacy Windows status honestly reports its inherited logon environment.
11
+
5
12
  ## 0.4.0 — 2026-10-09
6
13
 
7
14
  Verified DeepSeek Harness: `0.2.1-alpha.1` (the current release, and the one this profile runs), also `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1` and `0.1.7-alpha.2`. Release notes: [`docs/releases/v0.4.0.md`](docs/releases/v0.4.0.md).
package/README.md CHANGED
@@ -142,8 +142,7 @@ dsh plugin --profile web exec dsh-update-status-service install \
142
142
  --workspace "$PWD"
143
143
 
144
144
  # 3. Stop the terminal-started dsh web yourself, then activate the service.
145
- dsh plugin --profile web exec dsh-update-status-service activate \
146
- --workspace "$PWD"
145
+ dsh plugin --profile web exec dsh-update-status-service activate
147
146
  ```
148
147
 
149
148
  `activate` stops if the port is still busy instead of killing anything, so the DSH you are already using is never taken down by surprise. It also checks afterwards that the service it just started really took over; a service that begins failing immediately is reported as a failure, not as a success. From then on, **Restart** lists what is running, asks for a second confirmation when something is, and lets DSH exit; the service starts it again and the page comes back on its own.
@@ -156,7 +155,7 @@ Only one thing may start DSH Web on that port. If another tool starts its own `d
156
155
  | Linux | `systemd --user` | at user login | Implemented; verify on your host | `loginctl enable-linger` is optional for running after logout and is never enabled automatically. The unit template is render-tested but has not been run on a real Linux host yet. |
157
156
  | Windows | Task Scheduler + current-user wrapper | at user logon | Template supplied; verify on Windows before production use | No administrator account required. |
158
157
 
159
- The generated service files contain absolute paths and **no secrets** — never your API keys, npm tokens, cookies or proxy credentials. Native files, logs, status and removal: [user-service supervision](docs/service-supervision.md). To check Linux or Windows on your own machine: [platform acceptance](docs/platform-acceptance.md).
158
+ The generated service files contain absolute paths, one audited `PATH`, and **no secrets** — never your API keys, npm tokens, cookies or proxy credentials. By default `install` saves the PATH of the terminal that ran it, so Homebrew, MacPorts, Nix, asdf, Conda, Cargo and hand-installed tools all follow the same rule; `--path-source minimal` keeps a fixed environment and `--service-path` supplies an explicit one. `plan --dry-run` shows the final value and every warning before anything is written. Native files, logs, status and removal: [user-service supervision](docs/service-supervision.md). To check Linux or Windows on your own machine: [platform acceptance](docs/platform-acceptance.md).
160
159
 
161
160
  A restart interrupts running work and does not save it. If DSH cannot list what is running, cannot verify that your service owns the current process, or was started by hand or by the desktop app, it refuses to restart at all — a web page cannot talk it into it.
162
161
 
@@ -173,13 +172,14 @@ If you want DSH's stock policy instead (a non-loopback page never persists setti
173
172
 
174
173
  ## Compatibility
175
174
 
176
- Current release: plugin **`0.3.2`** is verified against DeepSeek Harness **`0.2.1-alpha.1`** (the current release), **`0.2.0-rc.2`**, **`0.2.0-rc.1`**, **`0.1.7-rc.2`**, **`0.1.7-rc.1`** and **`0.1.7-alpha.2`**.
175
+ Current release: plugin **`0.4.1`** is verified against DeepSeek Harness **`0.2.1-alpha.1`** (the current release), **`0.2.0-rc.2`**, **`0.2.0-rc.1`**, **`0.1.7-rc.2`**, **`0.1.7-rc.1`** and **`0.1.7-alpha.2`**.
177
176
 
178
177
  ### Which plugin version goes with which DeepSeek Harness version
179
178
 
180
179
  | Plugin | Verified DeepSeek Harness | On npm | What that version is |
181
180
  | --- | --- | --- | --- |
182
- | **`0.4.0`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | `latest` | Restart you can trust: the service stops instead of restarting forever, the button needs the service to really own the process, and a disabled button shows the two commands that recover it. Settings also shows when the service started |
181
+ | **`0.4.1`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | `latest` | The service saves the PATH used during installation, so tools installed by any package manager stay available after DSH is supervised |
182
+ | **`0.4.0`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | published | Restart you can trust: the service stops instead of restarting forever, the button needs the service to really own the process, and a disabled button shows the two commands that recover it. Settings also shows when the service started |
183
183
  | **`0.3.2`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | published | Documentation only: the version table and every past release note are now plain language. Behaves exactly like `0.3.1` |
184
184
  | **`0.3.1`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | published | Small polish: the settings page explains Restart too, and a long job name can no longer push the buttons away. |
185
185
  | **`0.3.0`** | `0.2.1-alpha.1`, `0.2.0-rc.2`, `0.2.0-rc.1`, `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.7-alpha.2` | published | Adds **Restart** to the panel, plus the one-command service setup it needs. Without that service the button stays disabled and says why. |
package/README.zh.md CHANGED
@@ -141,8 +141,7 @@ dsh plugin --profile web exec dsh-update-status-service install \
141
141
  --workspace "$PWD"
142
142
 
143
143
  # 3. 由你自己在旧终端停止直启的 dsh web,再激活服务。
144
- dsh plugin --profile web exec dsh-update-status-service activate \
145
- --workspace "$PWD"
144
+ dsh plugin --profile web exec dsh-update-status-service activate
146
145
  ```
147
146
 
148
147
  `activate` 发现端口还被占着就停手,绝不去杀进程,所以你正在用的 DSH 不会被突然掐掉;激活之后它还会确认服务真的接管了——一启动就不停失败的服务会被当作失败报出来,而不是假装成功。这之后就交给**重启**按钮:它先列出正在跑的东西,有东西在跑时再问一次,然后才让 DSH 退出;服务把新的拉起来,页面自己回来。
@@ -155,7 +154,7 @@ dsh plugin --profile web exec dsh-update-status-service activate \
155
154
  | Linux | `systemd --user` | 用户登录 | 已实现;请在你自己机器上验证 | 想在登出后继续运行,可自行开启 `loginctl enable-linger`(本插件不会替你开)。 |
156
155
  | Windows | 任务计划程序 + 当前用户 wrapper | 用户登录 | 已实现;请在你自己机器上验证 | 不需要管理员。 |
157
156
 
158
- 生成的服务文件里只有绝对路径,**没有密钥**——不会带上你终端里的 API key、npm token、cookie 或代理凭据。原生文件、日志、状态与卸载见[用户服务监督说明](docs/service-supervision.md);想在 Linux 或 Windows 上自己确认,见[跨平台验收步骤](docs/platform-acceptance.md)。
157
+ 生成的服务文件里只有绝对路径、一条可审计的 `PATH`,以及**没有密钥**——不会带上你终端里的 API key、npm token、cookie 或代理凭据。默认情况下,`install` 会保存执行安装命令的终端 PATH,因此 Homebrew、MacPorts、Nix、asdf、Conda、Cargo 和手工安装走的是同一套规则;`--path-source minimal` 保留固定环境,`--service-path` 则使用你明确给出的完整 PATH。`plan --dry-run` 会在写文件前列出最终值和全部提示。原生文件、日志、状态与卸载见[用户服务监督说明](docs/service-supervision.md);想在 Linux 或 Windows 上自己确认,见[跨平台验收步骤](docs/platform-acceptance.md)。
159
158
 
160
159
  重启会中断正在跑的工作,且不会保存。如果 DSH 列不出正在跑什么、无法确认当前进程确实由你装的服务拥有,或者它本来就是终端直启、桌面应用托管的,它会直接拒绝重启——网页端说什么都没用。
161
160
 
@@ -172,13 +171,14 @@ DSH 对来源不是 loopback(`localhost` / `127.0.0.1`)的页面会关闭 Ho
172
171
 
173
172
  ## 兼容性
174
173
 
175
- 当前发布:插件 **`0.3.2`** 已针对 DeepSeek Harness **`0.2.1-alpha.1`**(当前发布版)、**`0.2.0-rc.2`**、**`0.2.0-rc.1`**、**`0.1.7-rc.2`**、**`0.1.7-rc.1`** 与 **`0.1.7-alpha.2`** 验证。
174
+ 当前发布:插件 **`0.4.1`** 已针对 DeepSeek Harness **`0.2.1-alpha.1`**(当前发布版)、**`0.2.0-rc.2`**、**`0.2.0-rc.1`**、**`0.1.7-rc.2`**、**`0.1.7-rc.1`** 与 **`0.1.7-alpha.2`** 验证。
176
175
 
177
176
  ### 插件版本与 DeepSeek Harness 版本的对应关系
178
177
 
179
178
  | 插件版本 | 已验证的 DeepSeek Harness | npm 发布状态 | 该版本是什么 |
180
179
  | --- | --- | --- | --- |
181
- | **`0.4.0`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | `latest` | 重启变得靠得住:服务连续失败会停下而不是无限重启;按钮要求系统服务真正托管当前进程;禁用时会给出两条可复制的恢复命令。设置页还能看到服务启动时间 |
180
+ | **`0.4.1`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | `latest` | 安装服务时保存当时使用的 PATH,不论工具由哪种包管理器安装,受托管后仍能找到 |
181
+ | **`0.4.0`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | 已发布 | 重启变得靠得住:服务连续失败会停下而不是无限重启;按钮要求系统服务真正托管当前进程;禁用时会给出两条可复制的恢复命令。设置页还能看到服务启动时间 |
182
182
  | **`0.3.2`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | 已发布 | 只改文档:版本对照表和历史发布说明都改成人话。功能与 `0.3.1` 完全一样 |
183
183
  | **`0.3.1`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | 已发布 | 小补丁:设置页也说明了重启前提,长任务名不再把按钮挤走。 |
184
184
  | **`0.3.0`** | `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.2.0-rc.1`、`0.1.7-rc.2`、`0.1.7-rc.1`、`0.1.7-alpha.2` | 已发布 | 面板新增**重启**,以及它需要的那条服务安装命令;没装服务时按钮保持禁用并说明原因。 |
@@ -10,7 +10,7 @@ Three steps, each one safe to stop at. If a step fails, the section below says w
10
10
  dsh plugin --profile web exec dsh-update-status-service plan --workspace "$PWD" --dry-run
11
11
  ```
12
12
 
13
- Check the printed Node path, DSH path and port against what you actually run. Override any of them with `--node`, `--dsh`, `--port`.
13
+ Check the printed Node path, DSH path, absolute native-service command, port and **servicePath** against what you actually run. The default `current` value should contain the directories where your terminal finds the tools you need; warnings are visible before anything is written. Override launch facts with `--node`, `--dsh`, `--port`; use `--path-source minimal` for a fixed PATH, or `--service-path` (plus `--service-pathext` on Windows when needed) for an explicit one.
14
14
 
15
15
  ## 2. Write the service definition — still starts nothing
16
16
 
@@ -18,12 +18,12 @@ Check the printed Node path, DSH path and port against what you actually run. Ov
18
18
  dsh plugin --profile web exec dsh-update-status-service install --workspace "$PWD"
19
19
  ```
20
20
 
21
- It runs a boot precheck that does not bind the port. If that precheck fails, nothing is written.
21
+ It runs a boot precheck that does not bind the port and uses the exact same PATH that will be written into the service. If that precheck fails, nothing is written. The receipt keeps that PATH, its source, capture time, PATHEXT on Windows and all warnings; later commands reuse it instead of reading the shell that happens to invoke them.
22
22
 
23
23
  ## 3. Stop your terminal's `dsh web` yourself, then
24
24
 
25
25
  ```sh
26
- dsh plugin --profile web exec dsh-update-status-service activate --workspace "$PWD"
26
+ dsh plugin --profile web exec dsh-update-status-service activate
27
27
  ```
28
28
 
29
29
  It refuses to start while the port is still busy — that is deliberate, so the DSH you are using is never pushed aside.
@@ -44,7 +44,7 @@ If **Restart** stays greyed out after activating, the running process was not re
44
44
  ## Removing it again
45
45
 
46
46
  ```sh
47
- dsh plugin --profile web exec dsh-update-status-service uninstall --workspace "$PWD"
47
+ dsh plugin --profile web exec dsh-update-status-service uninstall
48
48
  ```
49
49
 
50
50
  That removes only the definition this tool created. Logs are kept.
@@ -25,15 +25,14 @@ This document describes the explicit, opt-in setup. The plugin never installs a
25
25
  5. Activate the staged service:
26
26
 
27
27
  ```sh
28
- dsh plugin --profile web exec dsh-update-status-service activate \
29
- --workspace "$PWD"
28
+ dsh plugin --profile web exec dsh-update-status-service activate
30
29
  ```
31
30
 
32
31
  `activate` refuses to run while the requested port is occupied, and it never kills an existing process. After starting the service it also verifies that the service really owns a running process: a service that begins crash-looping makes `activate` fail with that reason instead of reporting success.
33
32
 
34
33
  6. Reopen the normal Web URL. The update panel should report a restart-capable supervised process. Perform the first real page restart yourself while present, then confirm that the page reconnects.
35
34
 
36
- Use `status`, `stop`, and `uninstall` with the same `--workspace` argument for later maintenance. `uninstall` preserves logs.
35
+ After `install`, the receipt under `DSH_HOME` is the source of truth. `activate`, `status`, `stop`, and `uninstall` need no workspace/profile/PATH arguments; use only `--dsh-home` when the receipt is outside the default `~/.dsh`. Unknown options are rejected instead of silently falling back to another receipt. The receipt also stores an absolute launchctl/systemctl/schtasks path, so these actions do not depend on the shell that invokes them. Status, stop and uninstall remain available even if the old Node/DSH path or workspace no longer exists; activate still checks all launch files before it starts anything. `uninstall` preserves logs and re-derives every deletion path from the validated fixed service identity, platform and current home — mutable receipt paths are never trusted.
37
36
 
38
37
  > `dsh plugin --profile web exec` is pnpm's profile-local executable runner. Do not assume `dsh-update-status-service` is globally installed.
39
38
 
@@ -45,9 +44,12 @@ The staged definition contains only explicit, non-secret launch facts:
45
44
  - profile, workspace, host, port, and `--no-open`;
46
45
  - `DSH_HOME` when explicitly configured;
47
46
  - an explicit `DSH_WEB_SUPERVISOR=dsh-update-status` marker;
48
- - private stdout/stderr log paths.
47
+ - private stdout/stderr log paths;
48
+ - one final `PATH`, its source (`current`, `minimal`, or `explicit`), capture time, Windows `PATHEXT` when applicable, and any warnings produced while resolving it.
49
+
50
+ It intentionally does **not** copy the full terminal environment. The default `current` mode snapshots only the PATH of the process that runs `plan`/`install`; no login or interactive shell startup file is executed. The selected Node directory is placed first, empty and relative entries are ignored, duplicates are removed in order, and missing absolute directories are preserved with a warning because a mount or tool directory may appear later. The result is persisted in the receipt and reused by preflight, the native definition, activation and status — later commands never recapture a different terminal PATH.
49
51
 
50
- It intentionally does **not** inherit the full terminal environment. If your DSH startup requires secret environment variables, configure them through your operating system's secret-management mechanism before activating the service; do not paste secrets into this plugin's configuration or issue tracker.
52
+ For a fixed deployment use `--path-source minimal`; to supply the exact value yourself use `--service-path "..."` (which implies the `explicit` source). On Windows `--service-pathext` can pin command extensions as well. `plan --dry-run` prints the final PATH, source, capture time, PATHEXT and every warning before anything is written; `install` repeats the final PATH before preflight, so running the two commands from different terminals cannot hide a changed input. A too-long value or a value containing NUL/line breaks is rejected rather than silently shortened. If your DSH startup requires other environment variables or secrets, configure them through your operating system's secret-management mechanism before activating the service; do not paste secrets into this plugin's configuration or issue tracker.
51
53
 
52
54
  ## macOS — LaunchAgent
53
55
 
@@ -57,6 +59,8 @@ The CLI stages a current-user plist at:
57
59
  ~/Library/LaunchAgents/com.idoall.dsh-update-status.web.plist
58
60
  ```
59
61
 
62
+ The plist receives the one PATH resolved above. This is package-manager-neutral: Homebrew, MacPorts, pkgsrc, Nix, asdf, Conda, Cargo and hand-installed tools work when their directory was already in the terminal PATH used for `install`; no prefix is hardcoded into the macOS renderer.
63
+
60
64
  It runs at login (`RunAtLoad`) and starts a small wrapper, also staged by the CLI:
61
65
 
62
66
  ```text
@@ -89,6 +93,8 @@ The CLI stages:
89
93
  ~/.config/systemd/user/dsh-update-status-web.service
90
94
  ```
91
95
 
96
+ The unit receives the persisted POSIX PATH, with literal `%` escaped so systemd cannot treat a directory as a specifier. WSL reports itself as Linux and therefore uses the same colon-separated rules; a Windows semicolon PATH is never written into the unit.
97
+
92
98
  It uses `Restart=on-failure`, so DSH's restart exit code causes systemd to relaunch it, and it bounds the loop with `StartLimitIntervalSec=300` / `StartLimitBurst=10`: ten starts inside five minutes stops the unit instead of respawning forever. It is a **user** service: normally it starts after that user logs in.
93
99
 
94
100
  ```sh
@@ -119,6 +125,8 @@ The CLI stages a current-user Task Scheduler definition and a PowerShell wrapper
119
125
  %LOCALAPPDATA%\dsh-update-status\
120
126
  ```
121
127
 
128
+ New receipts pin both the semicolon-separated PATH and PATHEXT in the PowerShell wrapper, including drive paths, UNC shares, spaces and Unicode; PATH keys are read case-insensitively. Old receipts preserve their historical Task Scheduler logon environment instead of being narrowed during an upgrade.
129
+
122
130
  The task starts at user logon with least privilege. The wrapper owns the long-running DSH child, restarts it after an intentional restart or unexpected exit, and stops after five starts that each die within 30 seconds (the same ceiling as macOS) instead of respawning forever. Use Task Scheduler to inspect the task named **DSH Update Status Web**.
123
131
 
124
132
  Windows behavior must be verified on a Windows host before it is described as production-supported; use `plan --dry-run` to inspect the generated XML and wrapper first.
@@ -152,8 +160,7 @@ Node/NVM upgrades can change the absolute Node path captured in a service defini
152
160
  To remove supervision without deleting DSH profiles, sessions, or plugin preferences:
153
161
 
154
162
  ```sh
155
- dsh plugin --profile web exec dsh-update-status-service uninstall \
156
- --workspace "$PWD"
163
+ dsh plugin --profile web exec dsh-update-status-service uninstall
157
164
  ```
158
165
 
159
166
  The command removes only the definition and receipt created by this installer. It preserves logs for diagnosis.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { c as parseSystemdMainPid, d as bootStampFile, f as countRecentRestarts, h as serviceLogDir, i as SYSTEMD_UNIT, m as recordBootStamp, p as dshHomeOf, r as LAUNCHD_LABEL, s as parseLaunchdTrackedPid, t as planFor, u as RESTART_STORM_WINDOW_MS } from "./plan-BornHBEZ.js";
1
+ import { C as recordBootStamp, S as dshHomeOf, _ as parseSystemdMainPid, a as planFor, b as bootStampFile, f as LAUNCHD_LABEL, g as parseLaunchdTrackedPid, p as SYSTEMD_UNIT, w as serviceLogDir, x as countRecentRestarts, y as RESTART_STORM_WINDOW_MS } from "./plan-D9Ybaj-3.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { basename, dirname, join, normalize, sep } from "node:path";
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  //#region src/shared/restart-evidence.ts
6
6
  /**
@@ -165,15 +165,135 @@ function parseWindowsTaskRunning(output) {
165
165
  return /(?:^|\W)running(?:\W|$)/i.test(output);
166
166
  }
167
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
168
280
  //#region src/service/plan.ts
169
281
  /** Build transparent per-OS user-service definitions; do not install them here. */
170
282
  const SERVICE_LABEL = LAUNCHD_LABEL;
171
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
+ }
172
292
  function xml(value) {
173
293
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
174
294
  }
175
295
  function systemd(value) {
176
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
296
+ return value.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
177
297
  }
178
298
  function powerShell(value) {
179
299
  return value.replace(/'/g, "''");
@@ -216,6 +336,24 @@ function servicePlatform(value) {
216
336
  if (value === "darwin" || value === "linux" || value === "win32") return value;
217
337
  throw new Error(`unsupported platform: ${value}; supported: macOS, Linux, Windows`);
218
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
+ }
219
357
  function specFromArgs(args, env) {
220
358
  const platform = servicePlatform(env.platform);
221
359
  const profile = valueAfter(args, "--profile") ?? env.env.DSH_PROFILE ?? "web";
@@ -233,6 +371,20 @@ function specFromArgs(args, env) {
233
371
  if (!isAbsolute(dshPath)) throw new Error("`--dsh` must be an absolute executable path");
234
372
  if (!isAbsolute(workspace)) throw new Error("`--workspace` must be an absolute path");
235
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
+ });
236
388
  return {
237
389
  schemaVersion: 1,
238
390
  platform,
@@ -246,6 +398,12 @@ function specFromArgs(args, env) {
246
398
  home,
247
399
  ...dshHome === void 0 ? {} : { dshHome },
248
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,
249
407
  supervisorMarker: SUPERVISOR_MARKER
250
408
  };
251
409
  }
@@ -292,7 +450,7 @@ function webArguments(spec) {
292
450
  }
293
451
  function launchdWrapper(spec, paths, uid) {
294
452
  const args = [spec.nodePath, ...webArguments(spec)].map(shQuote).join(" ");
295
- const kickstart = `launchctl kickstart -k gui/${String(uid)}/${spec.label}`;
453
+ const kickstart = `${shQuote(nativeServiceCommandOf(spec))} kickstart -k gui/${String(uid)}/${spec.label}`;
296
454
  return `#!/bin/sh
297
455
  # Managed by dsh-update-status. This wrapper, not launchd, owns the restart loop.
298
456
  #
@@ -349,7 +507,7 @@ function launchdDefinition(spec, paths) {
349
507
  const args = ["/bin/sh", wrapper].map((value) => ` <string>${xml(value)}</string>`).join("\n");
350
508
  const envXml = [
351
509
  ["HOME", spec.home],
352
- ["PATH", `${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`],
510
+ ["PATH", servicePathOf(spec)],
353
511
  ["DSH_WEB_SUPERVISOR", spec.supervisorMarker],
354
512
  ...spec.dshHome === void 0 ? [] : [["DSH_HOME", spec.dshHome]]
355
513
  ].map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
@@ -383,7 +541,7 @@ function systemdDefinition(spec) {
383
541
  const command = [spec.nodePath, ...webArguments(spec)].map((value) => `"${systemd(value)}"`).join(" ");
384
542
  const env = [
385
543
  `Environment="HOME=${systemd(spec.home)}"`,
386
- `Environment="PATH=${systemd(`${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin`)}"`,
544
+ `Environment="PATH=${systemd(servicePathOf(spec))}"`,
387
545
  `Environment="DSH_WEB_SUPERVISOR=${spec.supervisorMarker}"`,
388
546
  ...spec.dshHome === void 0 ? [] : [`Environment="DSH_HOME=${systemd(spec.dshHome)}"`]
389
547
  ].join("\n");
@@ -418,7 +576,7 @@ function windowsWrapper(spec, paths) {
418
576
  const dshHome = spec.dshHome === void 0 ? "" : `$env:DSH_HOME = '${powerShell(spec.dshHome)}'\n`;
419
577
  return `$ErrorActionPreference = 'Stop'
420
578
  $env:HOME = '${home}'
421
- $env:DSH_WEB_SUPERVISOR = '${spec.supervisorMarker}'
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}'
422
580
  ${dshHome}Set-Location -LiteralPath '${workspace}'
423
581
  $rapidWindowSeconds = ${String(30)}
424
582
  $rapidLimit = ${String(5)}
@@ -463,6 +621,7 @@ function windowsDefinition(spec, paths) {
463
621
  /** Generate a service plan without writing, starting, or stopping anything. */
464
622
  function planFor(spec, uid = 0) {
465
623
  const paths = pathsFor(spec);
624
+ const control = nativeServiceCommandOf(spec);
466
625
  if (spec.platform === "darwin") {
467
626
  const domain = `gui/${uid}`;
468
627
  return {
@@ -471,30 +630,30 @@ function planFor(spec, uid = 0) {
471
630
  definition: launchdDefinition(spec, paths),
472
631
  wrapper: launchdWrapper(spec, paths, uid),
473
632
  installCommand: [
474
- "launchctl",
633
+ control,
475
634
  "bootstrap",
476
635
  domain,
477
636
  paths.definitionFile
478
637
  ],
479
638
  startCommand: [
480
- "launchctl",
639
+ control,
481
640
  "kickstart",
482
641
  "-k",
483
642
  `${domain}/${spec.label}`
484
643
  ],
485
- recoverCommand: `launchctl kickstart -k ${domain}/${spec.label}`,
644
+ recoverCommand: `${shQuote(control)} kickstart -k ${domain}/${spec.label}`,
486
645
  statusCommand: [
487
- "launchctl",
646
+ control,
488
647
  "print",
489
648
  `${domain}/${spec.label}`
490
649
  ],
491
650
  stopCommand: [
492
- "launchctl",
651
+ control,
493
652
  "bootout",
494
653
  `${domain}/${spec.label}`
495
654
  ],
496
655
  uninstallCommand: [
497
- "launchctl",
656
+ control,
498
657
  "bootout",
499
658
  `${domain}/${spec.label}`
500
659
  ]
@@ -505,33 +664,33 @@ function planFor(spec, uid = 0) {
505
664
  paths,
506
665
  definition: systemdDefinition(spec),
507
666
  installCommand: [
508
- "systemctl",
667
+ control,
509
668
  "--user",
510
669
  "daemon-reload"
511
670
  ],
512
671
  startCommand: [
513
- "systemctl",
672
+ control,
514
673
  "--user",
515
674
  "enable",
516
675
  "--now",
517
676
  "dsh-update-status-web.service"
518
677
  ],
519
- recoverCommand: `systemctl --user reset-failed ${SYSTEMD_UNIT} && systemctl --user enable --now ${SYSTEMD_UNIT}`,
678
+ recoverCommand: `${shQuote(control)} --user reset-failed ${SYSTEMD_UNIT} && ${shQuote(control)} --user enable --now ${SYSTEMD_UNIT}`,
520
679
  statusCommand: [
521
- "systemctl",
680
+ control,
522
681
  "--user",
523
682
  "status",
524
683
  "dsh-update-status-web.service"
525
684
  ],
526
685
  stopCommand: [
527
- "systemctl",
686
+ control,
528
687
  "--user",
529
688
  "disable",
530
689
  "--now",
531
690
  "dsh-update-status-web.service"
532
691
  ],
533
692
  uninstallCommand: [
534
- "systemctl",
693
+ control,
535
694
  "--user",
536
695
  "disable",
537
696
  "--now",
@@ -544,7 +703,7 @@ function planFor(spec, uid = 0) {
544
703
  definition: windowsDefinition(spec, paths),
545
704
  wrapper: windowsWrapper(spec, paths),
546
705
  installCommand: [
547
- "schtasks.exe",
706
+ control,
548
707
  "/Create",
549
708
  "/TN",
550
709
  "DSH Update Status Web",
@@ -553,14 +712,14 @@ function planFor(spec, uid = 0) {
553
712
  "/F"
554
713
  ],
555
714
  startCommand: [
556
- "schtasks.exe",
715
+ control,
557
716
  "/Run",
558
717
  "/TN",
559
718
  "DSH Update Status Web"
560
719
  ],
561
- recoverCommand: "schtasks.exe /Run /TN \"DSH Update Status Web\"",
720
+ recoverCommand: `"${control}" /Run /TN "DSH Update Status Web"`,
562
721
  statusCommand: [
563
- "schtasks.exe",
722
+ control,
564
723
  "/Query",
565
724
  "/TN",
566
725
  "DSH Update Status Web",
@@ -569,13 +728,13 @@ function planFor(spec, uid = 0) {
569
728
  "LIST"
570
729
  ],
571
730
  stopCommand: [
572
- "schtasks.exe",
731
+ control,
573
732
  "/End",
574
733
  "/TN",
575
734
  "DSH Update Status Web"
576
735
  ],
577
736
  uninstallCommand: [
578
- "schtasks.exe",
737
+ control,
579
738
  "/Delete",
580
739
  "/TN",
581
740
  "DSH Update Status Web",
@@ -584,4 +743,4 @@ function planFor(spec, uid = 0) {
584
743
  };
585
744
  }
586
745
  //#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 };
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 };
@@ -16,9 +16,44 @@ interface ServiceSpec {
16
16
  readonly home: string;
17
17
  readonly dshHome?: string;
18
18
  readonly logDir: string;
19
+ /** Final audited PATH persisted by install and reused by every later action. */
20
+ readonly servicePath?: string;
21
+ readonly servicePathSource?: 'current' | 'minimal' | 'explicit';
22
+ readonly servicePathCapturedAt?: string;
23
+ /** Windows command extension lookup, persisted alongside PATH. */
24
+ readonly servicePathExt?: string;
25
+ /** Human-readable warnings retained in the receipt and dry-run output. */
26
+ readonly servicePathWarnings?: readonly string[];
27
+ /** Absolute launchctl/systemctl/schtasks path; later actions never search PATH. */
28
+ readonly nativeServiceCommand?: string;
19
29
  /** Written by the user-service definition, never inferred from a TTY. */
20
30
  readonly supervisorMarker: 'dsh-update-status';
21
31
  }
32
+ interface ServicePaths {
33
+ readonly stateFile: string;
34
+ readonly definitionFile: string;
35
+ /** macOS and Windows: the wrapper that owns the restart loop. */
36
+ readonly wrapperFile?: string;
37
+ readonly stdoutFile: string;
38
+ readonly stderrFile: string;
39
+ }
40
+ interface ServicePlan {
41
+ readonly spec: ServiceSpec;
42
+ readonly paths: ServicePaths;
43
+ readonly definition: string;
44
+ readonly wrapper?: string;
45
+ readonly installCommand: readonly string[];
46
+ readonly startCommand: readonly string[];
47
+ /**
48
+ * One copy-only line that lets the service take over again after it stopped
49
+ * (a tripped start limit, a given-up wrapper). Displayed only — never run by
50
+ * the plugin.
51
+ */
52
+ readonly recoverCommand: string;
53
+ readonly statusCommand: readonly string[];
54
+ readonly stopCommand: readonly string[];
55
+ readonly uninstallCommand: readonly string[];
56
+ }
22
57
  interface CliEnvironment {
23
58
  readonly platform: NodeJS.Platform;
24
59
  readonly home: string;
@@ -49,6 +84,7 @@ type ServiceVerification = {
49
84
  };
50
85
  //#endregion
51
86
  //#region src/service/cli.d.ts
87
+ export declare function serialisePlan(plan: ServicePlan): string;
52
88
  export interface CliHooks {
53
89
  readonly preflight?: (spec: ServiceSpec) => void;
54
90
  /** Injected in tests; defaults to the real post-activation verification. */
@@ -1,6 +1,6 @@
1
- import { a as WINDOWS_TASK_NAME, c as parseSystemdMainPid, d as bootStampFile, f as countRecentRestarts, h as serviceLogDir, i as SYSTEMD_UNIT, l as parseWindowsTaskRunning, n as specFromArgs, s as parseLaunchdTrackedPid, t as planFor, u as RESTART_STORM_WINDOW_MS } from "./plan-BornHBEZ.js";
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
2
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
3
+ import { dirname, isAbsolute } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { createServer } from "node:net";
6
6
  async function observeServiceState(spec, run, uid) {
@@ -103,8 +103,15 @@ Options:
103
103
  --host <127.0.0.1|0.0.0.0> Bind host (default: 127.0.0.1)
104
104
  --port <1..65535> Web port (default: 3080)
105
105
  --node <path> Absolute Node executable (default: current Node)
106
- --dsh <path> Absolute dsh executable (default: resolve from PATH)
106
+ --dsh <path> Absolute dsh executable (default: resolve from profile)
107
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.
108
115
 
109
116
  Workflow: plan --dry-run → install → stop the terminal-started DSH yourself → activate.
110
117
 
@@ -114,7 +121,7 @@ makes activate fail instead of reporting success.
114
121
  `;
115
122
  }
116
123
  function assertSafeText(value, description) {
117
- 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`);
118
125
  }
119
126
  function assertReadableRegular(path, description) {
120
127
  assertSafeText(path, description);
@@ -135,20 +142,73 @@ function assertExecutable(path, description) {
135
142
  throw new Error(`${description} is not executable: ${path}`);
136
143
  }
137
144
  }
138
- 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");
139
154
  for (const [description, value] of [
140
155
  ["profile", spec.profile],
141
156
  ["workspace", spec.workspace],
142
157
  ["home", spec.home],
143
158
  ["log directory", spec.logDir],
144
- ["host", spec.host]
145
- ]) 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");
146
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");
147
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");
148
- assertExecutable(spec.nodePath, "Node executable");
149
- assertReadableRegular(spec.dshPath, "dsh executable");
150
- assertSafeText(spec.workspace, "workspace");
151
- 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
+ }
152
212
  }
153
213
  function atomicWrite(path, content) {
154
214
  mkdirSync(dirname(path), {
@@ -182,19 +242,31 @@ function ensureReplaceable(path, expected, managed) {
182
242
  }
183
243
  function receiptOwnsPath(previous, path) {
184
244
  if (previous === void 0) return false;
185
- return previous.paths.definitionFile === path || previous.paths.stateFile === path || previous.paths.wrapperFile === path;
245
+ const owned = pathsFor(previous.spec);
246
+ return owned.definitionFile === path || owned.stateFile === path || owned.wrapperFile === path;
186
247
  }
187
248
  function receiptTextOf(receipt) {
188
249
  return JSON.stringify(receipt, null, 2) + "\n";
189
250
  }
190
- function readReceiptIfPresent(path) {
191
- return existsSync(path) ? readReceipt(path) : void 0;
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;
192
256
  }
193
- function readReceipt(path) {
257
+ function readReceipt(path, env) {
194
258
  try {
195
259
  const value = JSON.parse(readFileSync(path, "utf8"));
196
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");
197
- 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
+ };
198
270
  } catch (error) {
199
271
  throw new Error(`cannot read managed service receipt ${path}: ${error instanceof Error ? error.message : String(error)}`);
200
272
  }
@@ -207,6 +279,7 @@ async function portIsFree(host, port) {
207
279
  });
208
280
  }
209
281
  function serialisePlan(plan) {
282
+ const inheritedWindowsPath = plan.spec.platform === "win32" && plan.spec.servicePath === void 0;
210
283
  return JSON.stringify({
211
284
  platform: plan.spec.platform,
212
285
  label: plan.spec.label,
@@ -216,6 +289,14 @@ function serialisePlan(plan) {
216
289
  workspace: plan.spec.workspace,
217
290
  host: plan.spec.host,
218
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
+ },
219
300
  definitionFile: plan.paths.definitionFile,
220
301
  wrapperFile: plan.paths.wrapperFile,
221
302
  stateFile: plan.paths.stateFile,
@@ -233,38 +314,9 @@ function serialisePlan(plan) {
233
314
  }
234
315
  }, null, 2) + "\n";
235
316
  }
236
- function commandsForActivate(plan, uid) {
237
- if (plan.spec.platform === "darwin") return [[
238
- "launchctl",
239
- "bootstrap",
240
- `gui/${uid}`,
241
- plan.paths.definitionFile
242
- ]];
243
- if (plan.spec.platform === "linux") return [[
244
- "systemctl",
245
- "--user",
246
- "daemon-reload"
247
- ], [
248
- "systemctl",
249
- "--user",
250
- "enable",
251
- "--now",
252
- "dsh-update-status-web.service"
253
- ]];
254
- return [[
255
- "schtasks.exe",
256
- "/Create",
257
- "/TN",
258
- "DSH Update Status Web",
259
- "/XML",
260
- plan.paths.definitionFile,
261
- "/F"
262
- ], [
263
- "schtasks.exe",
264
- "/Run",
265
- "/TN",
266
- "DSH Update Status Web"
267
- ]];
317
+ function commandsForActivate(plan, _uid) {
318
+ if (plan.spec.platform === "darwin") return [plan.installCommand];
319
+ return [plan.installCommand, plan.startCommand];
268
320
  }
269
321
  function invoke(command, run) {
270
322
  const [file, ...args] = command;
@@ -286,7 +338,8 @@ function preflight(spec) {
286
338
  env: {
287
339
  HOME: spec.home,
288
340
  ...spec.dshHome === void 0 ? {} : { DSH_HOME: spec.dshHome },
289
- 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 } : {}
290
343
  }
291
344
  });
292
345
  if (result.status !== 0) {
@@ -294,6 +347,32 @@ function preflight(spec) {
294
347
  throw new Error(`DSH boot-free preflight failed; no service definition was written: ${diagnostic}`);
295
348
  }
296
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
+ }
297
376
  async function main(argv = process.argv.slice(2), env = environment(), run = runner, hooks = {}) {
298
377
  const [action, ...rawArgs] = argv;
299
378
  if (action === void 0 || action === "--help" || action === "-h") {
@@ -311,19 +390,27 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
311
390
  process.stderr.write(`Unknown command: ${action}\n${usage()}`);
312
391
  return 2;
313
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
+ }
314
399
  const dryRun = rawArgs.includes("--dry-run") || action === "plan";
315
400
  const args = rawArgs.filter((value) => value !== "--dry-run");
316
401
  try {
317
- const spec = specFromArgs(args, env);
318
- validateSpec(spec);
319
- const plan = planFor(spec, env.uid ?? 0);
320
- if (dryRun) {
321
- process.stdout.write(serialisePlan(plan));
322
- return 0;
323
- }
324
- 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);
325
412
  (hooks.preflight ?? preflight)(spec);
326
- const previous = readReceiptIfPresent(plan.paths.stateFile);
413
+ const previous = readReceiptIfPresent(plan.paths.stateFile, env);
327
414
  ensureReplaceable(plan.paths.definitionFile, plan.definition, receiptOwnsPath(previous, plan.paths.definitionFile));
328
415
  if (plan.wrapper !== void 0 && plan.paths.wrapperFile !== void 0) ensureReplaceable(plan.paths.wrapperFile, plan.wrapper, receiptOwnsPath(previous, plan.paths.wrapperFile));
329
416
  const receiptText = receiptTextOf({
@@ -343,7 +430,9 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
343
430
  process.stdout.write(`Staged user service definition: ${plan.paths.definitionFile}\nNo service has started. Stop your terminal-started DSH, then run activate.\n`);
344
431
  return 0;
345
432
  }
346
- 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"));
347
436
  if (action === "activate") {
348
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.`);
349
438
  for (const command of commandsForActivate(plan, env.uid ?? 0)) invoke(command, run);
@@ -369,9 +458,9 @@ async function main(argv = process.argv.slice(2), env = environment(), run = run
369
458
  }
370
459
  const result = run(plan.uninstallCommand[0], plan.uninstallCommand.slice(1));
371
460
  if (result.status !== 0) process.stderr.write(`Warning: ${result.stderr || result.stdout || "service was not loaded"}\n`);
372
- rmSync(receipt.paths.definitionFile, { force: true });
373
- if (receipt.paths.wrapperFile !== void 0) rmSync(receipt.paths.wrapperFile, { force: true });
374
- 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 });
375
464
  process.stdout.write("Removed the managed user-service definition. Logs were preserved.\n");
376
465
  return 0;
377
466
  } catch (error) {
@@ -383,4 +472,4 @@ if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) main().the
383
472
  process.exitCode = code;
384
473
  });
385
474
  //#endregion
386
- export { main };
475
+ export { main, serialisePlan };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-update-status",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "DeepSeek Harness Web plugin for version status, copy-only upgrade guidance, and safe restart when an explicit user-level supervisor is installed.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",