dsh-home-hosted 0.8.1 โ†’ 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Ask for a Gitea, a Jellyfin, an FTP server; `dsh-home-hosted` sets up the entrie
16
16
  [![CI](https://github.com/NamesMT/dsh-home-hosted/actions/workflows/ci.yml/badge.svg)](https://github.com/NamesMT/dsh-home-hosted/actions/workflows/ci.yml)
17
17
  [![license](https://img.shields.io/npm/l/dsh-home-hosted?color=green)](./LICENSE)
18
18
  [![node](https://img.shields.io/badge/node-%3E%3D24-339933?logo=node.js&logoColor=white)](https://nodejs.org)
19
- [![dsh](https://img.shields.io/badge/dsh-%3E%3D0.2.0--rc.1-5b21b6)](https://github.com/deepseek-ai/dsh)
19
+ [![dsh](https://img.shields.io/badge/dsh-0.2.0--rc.1%20%7C%7C%200.2.1--rc.2-5b21b6)](https://github.com/deepseek-ai/dsh)
20
20
 
21
21
  [๐Ÿš€ Quick start](#-quick-start) ยท [๐ŸŽ›๏ธ What the plugin does](#-what-the-plugin-does) ยท [๐Ÿค– Agent tools](#-agent-tools) ยท [๐Ÿงฉ Depth](#-depth)
22
22
 
package/docs/BOOT.md CHANGED
@@ -92,6 +92,33 @@ stays `available` and its install reports "loads at the next login" instead of r
92
92
  `bootstrap`/`enable`/`print` steps need the domain, and they are skipped when it is unreachable. The
93
93
  daemon scope still needs root for `/Library/LaunchDaemons`.
94
94
 
95
+ ## Windows: logon, or boot as SYSTEM (opt-in)
96
+
97
+ Windows gets three mechanisms, because "starts at login" and "starts before login" are genuinely
98
+ different trades on that platform:
99
+
100
+ | mechanism | starts | who it runs as | needs |
101
+ | --- | --- | --- | --- |
102
+ | `windows-run` | at login | you, in your session | nothing |
103
+ | `windows-task` | at login | you, in your session | admin |
104
+ | `windows-boot` | at boot, before any login | `SYSTEM` | admin |
105
+
106
+ `windows-boot` is **never chosen by Automatic**, even though it is the only one that comes up without a
107
+ login. It runs in **session 0**, which has no interactive desktop and no loaded user profile โ€” so a
108
+ supervised server that wants a display, a GUI, or the user's own environment breaks there. The panel
109
+ itself is fine (server rows are launched with `--no-open`, so nothing tries to open a browser), which is
110
+ exactly why this is a per-machine decision rather than a default. Measured on Windows 11: a
111
+ `BootTrigger` task with the SYSTEM SID really does run in session 0, and the Task Scheduler accepts the
112
+ generated XML.
113
+
114
+ The two task mechanisms register under **different names** โ€” `home-hosted` and `home-hosted-boot` โ€” so
115
+ installing one does not replace the other and you can switch back. Only the boot task carries a boot
116
+ trigger, and only it uses the SYSTEM SID; neither stores a password, because a task that needs a named
117
+ account's credentials would be a credential store, not a boot entry.
118
+
119
+ Neither mechanism offers a stop: `/End` was measured to deliver no shutdown request and to leave
120
+ orphans behind, so the CLI's `down` remains the only stop that runs the supervised stop policy.
121
+
95
122
  ## Escaping is per setting, not per file
96
123
 
97
124
  A unit file is read by systemd, not by a shell, and its settings do not share one
@@ -137,6 +164,21 @@ keys (`Path=`, `Name=`, `Comment=`), and `Exec=` applies it on top of its shell
137
164
  Every ownership check reads the escaped marker too, or a marker carrying a backslash would
138
165
  be written escaped and searched for raw โ€” and the plugin would call its own entry foreign.
139
166
 
167
+ ## Once an entry is installed, it owns the panel's start and stop
168
+
169
+ The page's Start and Stop buttons drive that entry, not the CLI. On systemd that is
170
+ `systemctl restart|stop` โ€” a restart rather than a start, because the button appears when `/healthz`
171
+ does not answer and a wedged panel is still active to systemd, where a plain `start` is a no-op; a `Restart=always` unit restarts a process that dies *outside* systemd, so
172
+ the CLI's `down` brought the panel back seconds later and the page reported a stop the machine had
173
+ already undone. systemd's own manual is explicit: "When the death of the process is a result of
174
+ systemd operation (e.g. service stop or restart), the service will not be restarted." On launchd it
175
+ is `kickstart -k`/`kill` for the same reason (`KeepAlive`). A Windows scheduled task offers the start
176
+ only โ€” `schtasks /End` kills the process tree rather than shutting the panel down.
177
+
178
+ The wait after each action is for the same reason: a `Type=exec` unit reports started once `execve`
179
+ returned, before the panel bound its port, so the call polls `/healthz` and returns as soon as it
180
+ answers (~1.2 s measured here). The 20 s ceiling only bounds a panel that never answers.
181
+
140
182
  ## Enabling autostart hands the panel over
141
183
 
142
184
  An install alone proves nothing. The panel the plugin started keeps running, so nothing shows whether
@@ -161,10 +203,11 @@ whole design:
161
203
  4. start the entry;
162
204
  5. only then remove the other mechanisms' entries.
163
205
 
164
- The stop is always the CLI's `down`, never the new mechanism's own `restart`: the entry would then be
165
- starting a *second* panel, which home-hosted refuses while `run.json` names a live pid, so a
206
+ In a handover the stop is the CLI's `down`, never the new mechanism's own `restart`: the entry would
207
+ then be starting a *second* panel, which home-hosted refuses while `run.json` names a live pid, so a
166
208
  restart-in-place over an orphan only produces a failed unit. Waiting on the pid matters for the same
167
- reason.
209
+ reason. This is the one place the CLI is the right stop for a supervised panel โ€” it is the path that
210
+ retires the old entry, so nothing is left to restart it.
168
211
 
169
212
  Retirement is last, and deliberately not part of `install()`. Switching mechanism has to uninstall the
170
213
  old one, but the old entry is often what is currently running the panel: removing it during the install
@@ -202,7 +245,7 @@ Each mechanism sends it somewhere different, and the status line now names where
202
245
  | `systemd-user` / `systemd-system` | the journal โ€” the unit sets no `StandardOutput=`, so the detail names `journalctl --user -u <unit>` or `journalctl -u <unit>` |
203
246
  | `launchd-agent` / `launchd-daemon` | `<logDir>/<label>.out.log` and `<label>.err.log`, the two paths the generated plist carries |
204
247
  | `xdg-autostart` | nowhere persistent: a `.desktop` entry with `Terminal=false` has its output discarded by the session |
205
- | `windows-run` / `windows-task` | nowhere persistent either |
248
+ | `windows-run` / `windows-task` / `windows-boot` | nowhere persistent either |
206
249
 
207
250
  So the hint appears only where a file or a query actually exists โ€” and only once the entry is
208
251
  installed, because naming a log for an entry that was never written sends someone to a
package/docs/DESIGN.md CHANGED
@@ -38,11 +38,12 @@ its page.
38
38
  `onPortConflict: kill` (0.6.0), `persistent` (0.6.3), `restart <id>` (0.7.13) and the nullable-`label`
39
39
  patch (0.7.21) all predate the release this plugin pins. The plugin used to consult the answering
40
40
  panel's version and refuse or drop those keys (`KILL_UNSUPPORTED`), and later to retry a `restart` as a
41
- stop-then-start pair. Both are gone: it pins `home-hosted@^0.8.0`, which refuses an older panel
41
+ stop-then-start pair. Both are gone: it pins `home-hosted@^0.9.0`, which refuses an older panel
42
42
  outright, so neither check could ever fire again.
43
43
 
44
44
  An old state root is upgraded by running the latest 0.7.x **once, before installing 0.8** โ€” its first
45
- run moves the state into `.hh/`.
45
+ run moves the state into `.hh/`. A root a 0.8-era panel left on config schema 1 needs
46
+ `home-hosted migrate` before this plugin will write it; see [PANEL](PANEL.md#upgrading-an-existing-state-root).
46
47
 
47
48
  ## Tests
48
49
 
package/docs/PANEL.md CHANGED
@@ -56,10 +56,40 @@ plus its `.1`, and the 5 MB rotation โ€” is unchanged too, and this plugin's rea
56
56
  **0.8.0 stopped relocating an old state root, and this plugin follows it.** A 0.6.x root used to be
57
57
  moved into `.hh/` on 0.7's first run; 0.8 refuses it instead, before any command resolves a path into
58
58
  it, so a config at the old paths is never read as an empty panel. **Upgrading from 0.7 or earlier? Run
59
- the latest 0.7.x once first โ€” that release moves the state โ€” then install 0.8.** The plugin's floor is
60
- `0.8.0` for the same reason, so a 0.7.x panel is reported as unsupported rather than driven. That
61
- removed the last version-conditional code here: a stop-then-start fallback for a panel without
62
- `restart <id>`, and `panel.migrate`, whose CLI no longer performs the move.
59
+ the latest 0.7.x once first โ€” that release moves the state โ€” then install 0.8.** That removed the last
60
+ version-conditional code here: a stop-then-start fallback for a panel without `restart <id>`, and
61
+ `panel.migrate`, whose CLI no longer performs the move.
62
+
63
+ **0.9.0 raised the config schema to 2, and it changed what an unstamped file means.** Schema 2 folds
64
+ the Telegram singleton and the webhook list into one `notifications.destinations` list. The part that
65
+ bites here is the stamp: a file with no `meta.schema` is read as the **oldest** shape, so 0.9's `up`
66
+ refuses to start it โ€” `config schema 1 needs 1 migration`. This plugin writes workspace configs
67
+ directly whenever the panel does not answer, and it used to stamp them with `meta.writtenBy` alone, so
68
+ a config it created before any panel had run was unstartable on 0.9. It now stamps `meta.schema` on
69
+ every file it **creates**, reading the number from the pinned release rather than hard-coding it โ€” a
70
+ fixed `2` would break again at the next bump, from the other side.
71
+
72
+ A file that already exists **without** a stamp is refused instead of stamped: its content may still
73
+ need lifting, and stamping it would claim a migration that never ran and silently strand what the step
74
+ rewrites. Measured: a schema-2 stamp over an old `notifications.telegram` block makes 0.9 report the
75
+ key as unrecognized and ignore it, so the person's notifications end up silently off. Lifting a config
76
+ is `home-hosted migrate`'s job, and the refusal names it.
77
+
78
+ The floor is `0.9.0` for the same reason the pin is: a 0.8.x panel reads a schema-2 stamp as a *newer*
79
+ release's file and refuses that in turn, so no stamp satisfies both and there is no version-conditional
80
+ path. A 0.8.x panel is not a lesser-supported copy โ€” it is one this plugin cannot drive.
81
+
82
+ ### Upgrading an existing state root
83
+
84
+ A root a 0.8-era panel wrote is on config schema 1. Run the panel's own migration once, before letting
85
+ 0.9 โ€” or this plugin โ€” write it:
86
+
87
+ ```sh
88
+ home-hosted migrate --dry-run # print the steps, write nothing
89
+ home-hosted migrate --yes # keep a .bak beside each rewritten file
90
+ ```
91
+
92
+ Both sides are then on schema 2, and every write stays readable by the release that pinned it.
63
93
 
64
94
  **0.7.21 widens that nullable set by one field, and it closed a bug here.** Clearing a server's label
65
95
  sent an empty string, which the panel accepts and saves โ€” so the entry kept a blank label instead of
@@ -104,10 +134,43 @@ exact stock body, guards every assignment, and replaces the response's `end` for
104
134
  authentication plugin's own page (or an exotic response) is passed through untouched rather than fought
105
135
  over. `authNotice` turns it off; `{url}` is read per 401.
106
136
 
107
- ## Starting and replacing the panel
108
-
109
- `panel.start` runs the preferred CLI's `up`, which detaches itself and returns once the panel answers โ€”
110
- that is the whole out-of-the-box path when nothing is running.
137
+ ## Starting and stopping the panel
138
+
139
+ `panel.start` and `panel.stop` ask the *installed* autostart entry first, and fall back to the CLI's
140
+ `up`/`down` only when nothing supervises the panel. That fallback is the out-of-the-box path: with no
141
+ entry, the CLI is the only thing that can start one.
142
+
143
+ It cannot be the other way round. An entry that carries a restart policy โ€” `Restart=always` for
144
+ systemd, `KeepAlive` for launchd โ€” reads the CLI's `down` as an unexpected death and starts the panel
145
+ again seconds later, so "Stop the panel" came back on its own: a stop the page reported and the machine
146
+ did not honour. The mirror is just as wrong: a CLI `up` starts a panel *outside* the entry, which then
147
+ cannot be stopped or observed by it either. Whoever supervises the panel is who may change its state.
148
+
149
+ So an installed `systemd-user`/`systemd-system` entry answers with `systemctl start|restart` and
150
+ `stop`, and a `launchd-agent`/`launchd-daemon` with `kickstart`/`kickstart -k` and `kill`. The verb
151
+ follows the entry's own state, read before acting: `start` when the unit is not running, `restart`
152
+ when it is โ€” the Start button appears when `/healthz` is silent, and a wedged panel is still active,
153
+ where a plain start would be a no-op. Probing matters, because that trigger is a 2 s health probe: a
154
+ merely slow panel must not be restarted, and restarting it would stop the servers it supervises.
155
+
156
+ Mechanisms that start the panel from outside their own control โ€” a `.desktop` file, a `Run` value, a
157
+ container restart policy โ€” have no stop to offer, so those fall back to the CLI.
158
+
159
+ A Windows scheduled task is a partial case, and the one place Start may not recover a wedged panel: it
160
+ offers `schtasks /Run` and no stop. Measured on Windows 11: `/End` asks the task to shut down in no
161
+ form a program can observe (a PowerShell `trap` never ran) and, while reporting success, leaves child
162
+ processes alive โ€” so the CLI's `down` is the only stop that runs the panel's own stop policy. The task is registered `MultipleInstancesPolicy=IgnoreNew`, so `/Run` on a running
163
+ instance does nothing, and `schtasks` has no graceful restart โ€” pressing Start on a wedged Windows
164
+ panel therefore reports that the panel did not answer, rather than recovering it. Stopping still goes
165
+ through the CLI's `down`, which is graceful.
166
+
167
+ The entry's command returns as soon as the service manager accepted it, which is not the same as the
168
+ panel answering: a `Type=exec` unit counts as started once the binary was executed, before it binds
169
+ its port. So the call then polls `/healthz`, and stops as soon as the panel answers โ€” about a second
170
+ on the development machine this was measured on. The 20 s is only the ceiling for a panel that never
171
+ answers, matching the CLI's own startup budget. A stop follows the same rule with a 5 s ceiling: if
172
+ the entry's stop ran but the panel still answers, it was not the panel that entry supervises, and the
173
+ CLI's `down` finishes the job.
111
174
 
112
175
  `panel.takeover` replaces an answering panel with the preferred copy. Stopping the old panel also stops
113
176
  the non-persistent servers it supervises โ€” this dsh only if it is one, which an entry this plugin