dsh-home-hosted 0.7.3 → 0.7.5

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
@@ -103,6 +103,7 @@ the server management, not the thing this plugin is.
103
103
  | `home_hosted_servers_edit` | `create` · `update` · `delete` |
104
104
  | `home_hosted_autostart_manage` | `install` · `uninstall` |
105
105
  | `home_hosted_ui_manage` | `status` · `update` · `revert` · `switch` (official asset or local zip) |
106
+ | `home_hosted_panel_logs` | The panel's own console output. Needs no API token, so it works when the panel is up but not answering. |
106
107
 
107
108
  Every server tool names its `workspace`, because an id is only unique inside one.
108
109
 
@@ -163,6 +164,16 @@ the CLI, so a row for it could only crash-loop.) Server entries of every other k
163
164
 
164
165
  </details>
165
166
 
167
+ <details>
168
+ <summary><b>Read more</b></summary>
169
+
170
+ Topic docs, one per area — written for whoever has to touch it next:
171
+
172
+ [Boot autostart](docs/BOOT.md) · [the panel](docs/PANEL.md) · [server entries](docs/ENTRIES.md) ·
173
+ [workspaces and several panels](docs/WORKSPACES.md) · [agent tools](docs/AGENT-TOOLS.md)
174
+
175
+ </details>
176
+
166
177
  <details>
167
178
  <summary><b>Managed entry and port policy</b></summary>
168
179
 
@@ -6,9 +6,10 @@ Related: [server entries](ENTRIES.md)
6
6
 
7
7
  ## Agent tools are merged and on by default
8
8
 
9
- Seven tools, not ten: `servers_lifecycle` carries start/stop/restart, `servers_edit` carries
9
+ Eight tools, not ten: `servers_lifecycle` carries start/stop/restart, `servers_edit` carries
10
10
  create/update/delete, `autostart_manage` carries install/uninstall, `ui_manage` drives the panel's own
11
- UI, and `workspaces_list` is the read-only way to see every workspace. They register on by default;
11
+ UI, `panel_logs` reads the panel's console, and `workspaces_list` is the read-only way to see every
12
+ workspace. They register on by default;
12
13
  what gates a mutating call is the session's own permission mode, not a plugin-level default.
13
14
 
14
15
  A description is read at the approval prompt, so each is one line a person can glance at: what the tool
package/docs/BOOT.md CHANGED
@@ -191,6 +191,53 @@ fallback gets a command that works.
191
191
  Enabling and switching differ only in the retirement: a switch already has an entry keeping the panel
192
192
  alive, so its plan carries the old mechanism's removal.
193
193
 
194
+ ## Where the entry's own output goes
195
+
196
+ A boot-run panel's output does **not** go to the panel's own console
197
+ (`<home>/.hh/.logs/home-hosted.log`, which `panel_logs` reads and `docs/PANEL.md` describes).
198
+ Each mechanism sends it somewhere different, and the status line now names where:
199
+
200
+ | mechanism | where the output goes |
201
+ | --- | --- |
202
+ | `systemd-user` / `systemd-system` | the journal — the unit sets no `StandardOutput=`, so the detail names `journalctl --user -u <unit>` or `journalctl -u <unit>` |
203
+ | `launchd-agent` / `launchd-daemon` | `<logDir>/<label>.out.log` and `<label>.err.log`, the two paths the generated plist carries |
204
+ | `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 |
206
+
207
+ So the hint appears only where a file or a query actually exists — and only once the entry is
208
+ installed, because naming a log for an entry that was never written sends someone to a
209
+ command that finds nothing. The launchd paths come from one computation
210
+ (`launchdLogPaths`), shared with the plist builder, so the plist and the status line cannot
211
+ name different files.
212
+
213
+ ## What counts as "failing"
214
+
215
+ A unit's state comes from `systemctl show -p Result`. The value is compared against systemd's own
216
+ failure enum — `failed`, `exit-code`, `signal`, `timeout`, `core-dump`, `watchdog`,
217
+ `start-limit-hit`, `oom-kill` — and anything else, `success` included, is not a failure. `oom-kill`
218
+ is the one worth reading correctly: it is what a container limit or the OOM killer produces, and a
219
+ unit that died that way reading as merely "disabled" is how someone stops looking for it.
220
+
221
+ ## The page says what a mechanism name means
222
+
223
+ The picker's values are the identifiers the setting stores — `systemd-system`, `systemd-user`,
224
+ `xdg-autostart`, `launchd-agent` — and they are not self-explanatory, so the page prints the host's own
225
+ one-line reason for the chosen one ("systemd is not the init system here (no /run/systemd/system)", "a
226
+ LaunchAgent loads at login, not at boot", "this needs root here, so the plugin stages the plist and
227
+ shows the sudo commands"). Those sentences come from `BootCandidate.reason`, which the host already
228
+ computed for every mechanism, available or not.
229
+
230
+ Because `auto` is not a mechanism but "let the host decide", the page also names what that decision
231
+ would be: `BootStatus.recommended` is the host's own `recommend()` answer, and `install()` falls through
232
+ to it when nothing is installed (`pickOf(mechanism ?? before.mechanism ?? before.recommended)`). So
233
+ "Automatic" says which mechanism the next install would use, instead of leaving a person to install it
234
+ and read the result. The sentence is only shown while nothing is installed under that mechanism, and it
235
+ is absent — never "undefined" — when an older panel does not send the field.
236
+
237
+ The `Details` block describes the mechanism that is actually **installed**, which is a different one
238
+ while a switch is pending, so both it and the picker read one shared lookup rather than each formatting
239
+ the name their own way.
240
+
194
241
  ## Privilege
195
242
 
196
243
  Boot scope needs privilege somewhere on every platform: a system unit, or `loginctl enable-linger` on
package/docs/DESIGN.md CHANGED
@@ -38,7 +38,7 @@ its page.
38
38
  `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and an older panel
39
39
  handed either key can refuse to *boot* from the config. The plugin used to consult the answering
40
40
  panel's version and refuse or drop them (`KILL_UNSUPPORTED`). That is gone on purpose: it pins
41
- `home-hosted@^0.7.3` and autostarts its own copy, so a panel older than those keys is only reachable by
41
+ `home-hosted@^0.7.19` and autostarts its own copy, so a panel older than those keys is only reachable by
42
42
  deliberately preferring an old global install — and this is pre-1.0. Supporting older panels again means
43
43
  bringing that check back from history, not re-deriving it.
44
44
 
package/docs/ENTRIES.md CHANGED
@@ -61,6 +61,31 @@ The generated entry always binds loopback (`bind: 'local'`, `--host 127.0.0.1`):
61
61
  `--host 0.0.0.0` as a usage error, so repeating a running harness's network bind would produce an
62
62
  entry that can never start.
63
63
 
64
+ ## The page's editor covers the common fields on purpose
65
+
66
+ `src/client/entry-editor.tsx` renders eight entry fields — id, command, args, working directory,
67
+ label, port, autostart and port-conflict policy — and the panel's `serverSchema` has 23. The other
68
+ fifteen (`enabled`, `bind`, `persistent`, `env`, `dataEnvs`, `envFile`, `bootstrap`, `dependsOn`,
69
+ `resources`, `backupPaths`, `logBufferLines`, `backupIgnoreGenerated`, `health`, `restart`, `stop`)
70
+ are **deliberately out of scope**, not forgotten.
71
+
72
+ The page says so where it happens: *"Entries are managed in the home-hosted panel:"* followed by a
73
+ link to that panel, whose own editor sets every field. So the dsh page is a convenience for the
74
+ common ones, and the panel remains the place a full config is written.
75
+
76
+ Two properties make that safe, and both are deliberate:
77
+
78
+ - **A minimal diff cannot destroy what it does not render.** An edit sends only the fields that
79
+ differ from the stored entry, so `bootstrap`, `envFile` or a `restart` policy is left exactly as it
80
+ was — the editor cannot silently reset a key it does not show.
81
+ - **The scope is a recorded decision, not an accident.** `EDITOR_FIELDS` and `OUT_OF_SCOPE_FIELDS`
82
+ are exported, and `test/client/entry-editor` reads the panel's own schema and fails if any field is
83
+ neither rendered nor declared. A field the panel grows therefore cannot become reachable there and
84
+ silently absent here — it fails until somebody decides which it is.
85
+
86
+ The same file asserts the *rendered* labels, not just the list: a field can be declared and never
87
+ drawn, which is the exact shape this guards against.
88
+
64
89
  ## Persistence keeps dsh alive across a panel restart
65
90
 
66
91
  A managed entry asks for `persistent: true`: home-hosted then runs it under its nanny, which owns the
@@ -82,6 +107,13 @@ triggers its file watcher. A write to a workspace's `servers.config.json` does
82
107
  `autostart: false`, then flip `autostart`. A later write to an existing entry is a *changed
83
108
  definition*, which only takes effect at that entry's next start.
84
109
 
110
+ The file path has one subtlety worth knowing: the panel's API **merges** the nested groups
111
+ (`restart`, `health`, `stop`) key by key, and so must this write, or a partial patch would replace the
112
+ group and silently drop every sibling key — `{ restart: { maxRetries: 1 } }` would wipe `baseDelayMs`
113
+ and the rest, and the entry would fall back to the panel's defaults. The plugin mirrors the panel's own
114
+ `SERVER_MERGE_KEYS`/`CONTROL_MERGE_KEYS`, and a test reads those from the installed panel so the copy
115
+ cannot fall behind it.
116
+
85
117
  ## What the plugin owns
86
118
 
87
119
  Exactly `autostart`, `onPortConflict`, `persistent` and `stop.killPortHolders` (`OWNED_ENTRY_KEYS`).
package/docs/PANEL.md CHANGED
@@ -13,11 +13,38 @@ globally. An operator's `homeHostedCommand` always wins — that is an instructi
13
13
 
14
14
  The range is stated twice and both must agree: `dependencies['home-hosted']` in `package.json` and
15
15
  `EXPECTED_RANGE` in `resolve.ts` (what the page offers to install, and warns about when a global copy
16
- is older). Bumping one alone makes the page recommend a range the plugin is not built against. A bump
17
- is therefore those two plus the version-coupled tests — *unless* the release moved something this
18
- plugin names: a path under `.hh`, `CONFIG_SCHEMA`, `serverSchema`, `/api/settings`, or a CLI
19
- subcommand. 0.7.2 (the panel's reverse proxy, all under `.hh/.proxy/`) moved none of them, and neither
20
- did 0.7.3 (DNS-01 through the panel's own DNS accounts, and a Namecheap API provider).
16
+ is older). Bumping one alone makes the page recommend a range the plugin is not built against.
17
+ `test/resolve` **enforces** that agreement — it was a rule the docs stated and nothing checked, so
18
+ setting the manifest to `^0.7.99` while the constant stayed put failed nothing until the guard
19
+ existed. The same file asserts `MIN_SUPPORTED_VERSION` lies inside the advertised range, since a floor
20
+ above it would advertise a copy the plugin then refuses.
21
+
22
+ A bump is therefore those two plus the version-coupled tests — *unless* the release moved something
23
+ this plugin names: a path under `.hh`, `CONFIG_SCHEMA`, `serverSchema`, `/api/settings`, or a CLI
24
+ subcommand. Check that by **use**, not by filename: a file appearing in a diff cannot show that a
25
+ change reaches us, and the two disagree as soon as a comment lands in a schema file.
26
+
27
+ **0.7.18 and 0.7.19 did move one, and no filename showed it.** Upstream `applyPatch` now deletes a
28
+ top-level key set to `null` (`1f58a5d`), where it used to write it through; `serverSchema.label` also
29
+ began to admit `null` (`45d689e`). This plugin keeps its own copy of that function for the file
30
+ fallback, and the copies had drifted — the nested branch deleted, the top-level branch wrote the
31
+ `null` — so a top-level `null` that the panel deletes would have been stored, producing a config it
32
+ refuses to boot (`servers[0] ("smoke"): cwd must be a string (was null)`). One `applyPatchFields` now
33
+ serves both paths, and the refusal a `null` group gets is one rule `src/service.ts` reads too.
34
+
35
+ No shipped caller could reach the harmful case, and saying so is part of the finding: the agent tool
36
+ layer validates `patch` against the panel's own field types, so only `port` and `bootstrap` can carry
37
+ a `null` at all — exactly the two whose schema is `… | null` — while the page emits `port` alone. The
38
+ drift was real but latent; it is fixed because a copy of the panel's function is a fork of its
39
+ semantics, and the next field to allow `null` would have made it reachable.
40
+
41
+ Verified against a real 0.7.19 panel and its own CLI: `PATCH {port:null}` deletes the key; only `port`
42
+ and `bootstrap` have a schema that admits `null`; `_hh` is still exactly four routes; and every route,
43
+ subcommand and flag this plugin drives is unchanged. The console **file** — `.hh/.logs/home-hosted.log`
44
+ plus its `.1`, and the 5 MB rotation — is unchanged too, and this plugin's reader still matches
45
+ `home-hosted logs` line for line. A capability that needs a *newer* 0.7.x degrades rather than raising
46
+ the floor: `logs` (0.7.12) is a file read either way, and `restart <id>` (0.7.13) falls back to the
47
+ stop/start pair it replaced.
21
48
 
22
49
  That copy resolves to a pnpm path carrying a version and a peer hash, which moves on the next install
23
50
  and disappears when the profile is rebuilt. A boot entry that baked it in would fail exactly when it
@@ -65,6 +92,74 @@ response says the page will disconnect; the helper logs to `bin/panel-takeover.l
65
92
  can still carry `ok: false` — the page reads the payload's `detail`/`output` rather than trusting
66
93
  `envelope.ok`, so a refused control action says why instead of looking like a no-op.
67
94
 
95
+ ## The console is readable when nothing else is
96
+
97
+ A panel that is up but misbehaving is exactly when its API is hardest to reach: the token may be
98
+ stale, `writeVia` may be `file`, and the thing that explains the symptom is what the panel printed
99
+ while starting. So `panel.console` reads `<root>/.hh/.logs/home-hosted.log` off disk — no session, no
100
+ API token, no CLI, and no dependence on the panel answering — and the `panel_logs` agent tool exposes
101
+ it. It reads one rotation too (`<log>.1`), because the end of the file that just rotated away is
102
+ usually where a crash is, and it matches `home-hosted logs` line for line (verified against a real
103
+ panel at every count).
104
+
105
+ The read **grows its window** rather than sizing one from the count. A line's length is unbounded —
106
+ a stack trace, a JSON dump or a verbose error is one line and can be kilobytes — so a window sized
107
+ per *line* silently returns fewer lines than asked for: on 500 lines of ~2 KB, `lines: 100` returned
108
+ 50. The window instead grows backwards until `limit` lines are present or the file start is reached,
109
+ so a bounded request never reads an unbounded file and a short line still stops after one block
110
+ (20 lines of a 37.9 MB log reads 64 KiB). Home-hosted's own `readTail` grows for the same reason.
111
+
112
+ The rotation is joined the way **concatenating the files** joins it, not by gluing two line arrays.
113
+ A panel killed mid-write leaves `.1`'s last line unterminated, and concatenation makes that line and
114
+ the live file's first line one line (`rot2-partial` + `live1` → `rot2-partiallive1`); array-joining
115
+ invents a line break the log never had, and then "the last 2 lines" is one short. Sufficiency is
116
+ therefore counted in **separators** (`limit + 1`), the same test `logs` makes — counting lines stops
117
+ one early for exactly that reason.
118
+
119
+ A missing log is an empty one, not an error: a panel that has just started has nothing to say, and
120
+ reporting that as a failure would make the one diagnostic that always works look broken. The new tool
121
+ is **not** added to an existing settings file's allow-list — a tool that did not exist when that file
122
+ was written could not have been deselected, but granting it silently would expand a deliberate
123
+ selection, so the page's checkbox is how it is chosen.
124
+
125
+ ## What `/_hh` does not offer, deliberately
126
+
127
+ `/_hh` is the one unauthenticated surface a local caller can use, and it is deliberately tiny. Read
128
+ out of `src/api/control.ts` (mounted before the `/api/*` guard in `src/app.ts`), it registers exactly
129
+ **four** routes: `POST /shutdown`, and `POST /servers/:id/{start,stop,restart}`. Every one needs the
130
+ token from `run.json` plus a loopback peer, which is the whole point — a local `down` needs no session,
131
+ password or API token.
132
+
133
+ There is **no local-token path to the reverse proxy, backups, TLS, notifications or the config**. Those
134
+ live behind `/api/*` and a real credential. So a plugin capability over any of them would have to mint
135
+ a token and speak `/api`, and it must not add an unauthenticated `/api` route of its own to avoid that
136
+ — that is the rule the plugin is built on (the CLI drives a running panel through `/_hh`, never
137
+ `/api`). `_acme` is the only other route outside the guard (loopback-only, credentials the panel
138
+ generates, a single `/:action` for DNS-01); this plugin never touches it.
139
+
140
+ The practical consequence for a foreign panel is the three mechanisms `docs/WORKSPACES.md` lists: its
141
+ config file, its own CLI, or a token the user is asked about. A panel the user has not confirmed is
142
+ never reached by a fourth route invented here.
143
+
144
+ ## Two RPC endpoints have no caller, and that is fine
145
+
146
+ `servers.get` and `entries.restore` are handled by the service, typed in `RpcEndpoint`, and tested —
147
+ but no client or agent tool invokes either, which is the shape worth checking rather than assuming.
148
+
149
+ Both are **redundant surface, not unreachable capability**:
150
+
151
+ - `entries.restore`'s job is reached through `entries.remove`, which restores an entry this plugin
152
+ merely adopted instead of deleting it (`removeManagedEntry` → the `adopted` branch). That is proven
153
+ behaviourally, not by reading: `test/service` removes an adopted entry and asserts the panel got no
154
+ `DELETE` and the entry's own `onPortConflict` came back.
155
+ - `servers.get` returns one entry's view, and `servers.list` — which every consumer uses — returns
156
+ every entry's view for the workspace.
157
+
158
+ They are left in place rather than deleted: they are a coherent RPC surface for a caller that wants
159
+ one entry, deleting them is a wire change for no user-visible gain, and the capability each names is
160
+ already reachable. Recorded here so the next reader does not have to re-derive whether it was an
161
+ oversight.
162
+
68
163
  ## Credentials
69
164
 
70
165
  home-hosted keeps only an API token's hash, so a token this plugin did not create can never be
@@ -11,6 +11,23 @@ settings, secrets, logs and nanny state under `.hh/<workspace>/`, while the list
11
11
  vitals, backups, UI and `run.json` stay at `.hh/`. A server id is therefore only unique *inside* a
12
12
  workspace, and every call has to say which one it means.
13
13
 
14
+ The same rule reaches the page. The servers section is not remounted when the viewed workspace
15
+ changes, so anything it holds about a server has to carry the workspace it was made in — otherwise an
16
+ editor or a confirmation opened on workspace A's `web` stays live under workspace B and re-binds to
17
+ *B's* `web`. The displayed list is replaced on switch, so the ids simply stop matching; the plugin's
18
+ own in-flight keys (`busyKey`) already included the workspace, and the section's local selections now
19
+ do too.
20
+
21
+ The scoping is verified by driving the section under `happy-dom` and clicking it — open the editor,
22
+ switch workspace, read the screen — not only by calling the resolver, so the wiring for all three
23
+ selections is exercised. A control case re-renders in the *same* workspace and asserts the editor
24
+ stays open, so the failure cannot be mistaken for "it closed for some other reason".
25
+
26
+ The same lesson applies to the boot section's own stored state: `bootAttemptView` used to drop the
27
+ mechanism, so its note could only be gated on `state` and a persisted refusal survived a change of
28
+ preference — with the *old* mechanism's copy-pasteable commands. The view carries it now, and both
29
+ the fresh and persisted paths compare it.
30
+
14
31
  The plugin manages exactly one workspace — **the panel's `default`**, fixed — because its intent (the
15
32
  `dsh` entry, snapshots, reconcile) is a promise about one entry in one place. Making that a setting
16
33
  bought nothing but a way to write into the wrong workspace, so it is a constant. The page and the agent
@@ -58,8 +75,13 @@ that endpoint has, least invasive first:
58
75
  (`.hh/<workspace>/servers.config.json`, its default workspace unless the call names another) — no
59
76
  credential, but no live status either, and a running panel applies a changed definition at that
60
77
  entry's next start;
61
- - `cli` runs the CLI against its state root — the only runtime path the panel itself offers
62
- (`start`/`stop`; a restart is a stop then a start, because the CLI has no server restart);
78
+ - `cli` runs the CLI against its state root — the only runtime path the panel itself offers.
79
+ `start`/`stop` act on one entry; a restart is now the CLI's own `restart <id>` (home-hosted
80
+ 0.7.13), which is what the panel's UI does and leaves no window in which the entry is down. A
81
+ local panel older than that refused the command before touching anything, so the plugin falls
82
+ back to the stop-then-start pair and reports `downgraded` in the result — the refusal is
83
+ detected from the CLI's own text, including the `Unexpected argument` a 0.7.12 CLI prints for
84
+ the positional id;
63
85
  - `api` mints a token for that panel, enrols it and uses its HTTP API — the only way to get live
64
86
  status, and it replaces any token that panel had, which the question says outright.
65
87