home-hosted 0.3.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/AGENTS.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  `home-hosted` is a Node 24 / TypeScript harness for self-hosted servers: `up` starts a Hono/srvx
4
4
  panel (default `127.0.0.1:3999`) that supervises the entries in `$HHOSTED_HOME/servers.config.json`
5
- and serves a UI. User docs: `README.md`; UI authors: `UI_CREATION.md`.
5
+ and serves a UI. User docs: `README.md`, `SERVERS.md` (entries and port conflicts),
6
+ `NOTIFICATIONS.md`; UI authors: `UI_CREATION.md`.
6
7
 
7
8
  State lives only in `$HHOSTED_HOME` (default `~/.home-hosted`): `servers.config.json`,
8
- `.control-secrets.json` (0600), `.logs/`, `.tls/`, `.backups/`, `.ui/`, and `run.json` — the live
9
- daemon's pid/url/token, 0600. The package ships **no servers**: never commit a config, a seed
10
- entry, or a path that names one.
9
+ `.control-secrets.json` (0600: password hash, API token hash, Telegram bot token), `.logs/`, `.tls/`,
10
+ `.backups/`, `.ui/`, and `run.json` — the live daemon's pid/url/token, 0600. The package ships **no
11
+ servers**: never commit a config, a seed entry, or a path that names one.
11
12
 
12
13
  ## Commands
13
14
 
@@ -21,6 +22,9 @@ pnpm run quickcheck # eslint + tsc + vue-tsc for every UI under u
21
22
  pnpm exec vitest run # `pnpm test` is vitest in watch mode
22
23
  pnpm run check # quickcheck + vitest run --coverage
23
24
  pnpm run set-password # non-interactive through HHOSTED_PASSWORD
25
+ pnpm run set-token # --generate prints a new API token once
26
+ pnpm run migrate # bring the config up to this release's schema
27
+ pnpm exec tsx src/cli.ts init # scaffold a project (interactive; --yes for defaults)
24
28
  pnpm run media # regenerate docs/media (mockups, both served UIs, tour.gif)
25
29
  ```
26
30
 
@@ -44,11 +48,21 @@ exists, so the first release has to be published by hand.
44
48
  - `src/api/**` — one file per URL group (`$.routes.ts` = several routes), mirroring the path.
45
49
  - `src/shared/contracts.ts` — every ArkType schema (config, API and SSE DTOs), shared with the UIs;
46
50
  the OpenAPI spec is generated from it, never hand-written.
47
- - `src/config/` — `schema.ts` (on-disk shape), `store.ts` (validate/merge/atomic commit, reports
48
- `configError` instead of throwing on a bad file), `secrets.ts`, `seed.ts`.
49
- - `src/providers/` — stateless leaves: process, port, proc, health-check, host, telegram, archive.
51
+ - `src/config/` — `schema.ts` (on-disk shape, including the `meta` stamp), `parse.ts` (the tolerant
52
+ reader: unknown keys are reported and kept, everything else blocking), `store.ts` (validate/merge/
53
+ atomic commit, reports `configError`/`configWarnings` instead of throwing on a bad file),
54
+ `migrations.ts` (the schema constant and the ordered step registry), `secrets.ts`, `seed.ts`.
55
+ - `src/providers/` — stateless leaves: `process` (spawn, `terminate`, `terminatePid` for a process we
56
+ adopted), `port` (probe, holder lookup, `terminatePids`), `proc` (the sampler, plus
57
+ `processCarriesServerId` for ownership), health-check, host, telegram, archive.
50
58
  - `src/services/` — stateful orchestration: supervisor, control-server, state, auth + exposure,
51
- dependencies, history, log-buffer/log-files, notifications, host-monitor, backups, tls, ui.
59
+ dependencies, history, log-buffer/log-files, notifications, host-monitor, backups, tls, ui, plus
60
+ `init` (the scaffold behind `home-hosted init`: a manifest, a `.gitignore`, and the prompts stay in
61
+ the CLI). It names no server — the scaffold must stay as neutral as the supervisor.
62
+ - `src/middleware/auth.ts` — the `/api/*` guard, and `requestIdentity()`, the one place a request's
63
+ credentials are read: the `hh2_session` cookie or `Authorization: Bearer <api token>`. A token is
64
+ a first-class credential (same authority as a signed-in browser) and is verified from the secrets
65
+ file on every request, so `set-token` needs no restart.
52
66
  - `src/helpers/` — paths (`dataRoot` vs `projectDir`), daemon (run.json + a loopback probe that
53
67
  bypasses `fetch`, so TLS with a self-signed pair still answers), error, validator, atomic,
54
68
  template, env-file, openapi, factory.
@@ -75,7 +89,54 @@ exists, so the first release has to be published by hand.
75
89
  `http`) merge key-by-key, and an explicit `null` clears a key.
76
90
  - Two-sided bounds read inclusively (`'1 <= number.integer <= 512'`). `test/shared/contracts.test.ts`
77
91
  pins every boundary and the patch/schema parity — update it with any schema change.
92
+ - A new field in a **response** DTO is optional (`'x?'`) and its clients read it defensively. An
93
+ upgrade writes a new `uis/stock/dist` to disk while the old panel process is still serving, so a
94
+ new UI meets an older payload for a while — a required field there rejects the whole frame and
95
+ blanks the app. Request bodies and config keep their strict, defaulted shape.
78
96
  - Conventional commits; ESLint via `@antfu/eslint-config`; sparse comments.
97
+ - UI tests live in `uis/stock/test/`: pure modules on node, and a component that is worth
98
+ guarding mounts under `// @vitest-environment happy-dom` (see `number-field.test.ts`). Reach for
99
+ that rather than trusting a component to be thin: `NumberField`'s setter assumed the string a text
100
+ input reports, but Vue casts `<input type="number">` to a *number* first, so `raw.trim()` threw and
101
+ every value typed into a numeric field was silently discarded — no pure-module test could see it.
102
+ - A destructive action that is one click away confirms in a **popover** (`KillPortButton.vue`),
103
+ never by arming the same button for a second press: an impatient double click on an arming
104
+ button fires it. Keep the safe choice first in the popover's tab order.
105
+
106
+ ## Compatibility
107
+
108
+ Two surfaces outlive the release that wrote them: **configs absolutely, UIs within reason.** Breaking
109
+ either is a last resort, and never an accidental one.
110
+
111
+ - **A config written by an older release has to load in a newer one.** That direction is the priority:
112
+ add fields with defaults, never repurpose or remove one, and treat every existing key as permanent.
113
+ - **Both directions matter, and both are now handled.** An unrecognized key is read, reported and
114
+ left on disk instead of failing anything: it is the normal way a config from a newer release looks
115
+ here. Anything that is not merely unrecognized — a wrong value, a duplicate id, an unreadable file —
116
+ stops the panel instead of being papered over with defaults.
117
+ - **The UI moves in minor steps.** Routes, response fields and SSE frames are additive: keep the old
118
+ one and add the new one. A new response field is optional (`'x?'`) and read defensively, because an
119
+ upgrade writes a new `uis/stock/dist` while the old panel process keeps serving — and a
120
+ user-uploaded UI may be older than the panel it talks to.
121
+ - **Breaking is allowed; silent is not.** When nothing compatible can be done, say so in the final
122
+ answer *and* in the commit message with a `BREAKING CHANGE:` footer, naming the exact migration the
123
+ user must run.
124
+ - **A config records what wrote it.** Every write stamps a top-level `meta`
125
+ (`{ writtenBy, schema }`): the release that wrote the file and the config shape it wrote. An
126
+ unstamped file reads as the current schema, so nothing that existed before needed changing.
127
+ - **Nobody runs a config this release cannot read.** `up` refuses to start — exit 1, the exact
128
+ problem printed — when the file is unparseable, has an invalid value or a duplicate id, carries a
129
+ newer `meta.schema`, or has a registered migration pending. A panel that is *already* running keeps
130
+ the config it has and only reports the error, so a bad edit never disturbs supervision.
131
+ - **Unknown keys are dropped from the resolved config, kept on disk, and listed in a startup
132
+ warning.** Dropping one is the normal way a newer config looks here, so it must never fail the
133
+ group it sits in (that used to reset `control` — port, bind, auth policy — to schema defaults).
134
+ - **Migrations ship inside the package** (`src/config/migrations.ts`), are ordered, idempotent and
135
+ described in one line each. `home-hosted migrate` prints the plan, keeps `servers.config.json.bak`,
136
+ refuses to write a config it cannot read, and needs consent: `--yes`, `HHOSTED_MIGRATE=allow`, or a
137
+ person at a terminal. A detached daemon never migrates on its own. Starting a fetch of migration
138
+ code from GitHub was considered and rejected: the panel supervises processes, so remote code is an
139
+ RCE surface, and a migration would age against a newer store API anyway.
79
140
 
80
141
  ## Rules that matter
81
142
 
@@ -84,8 +145,18 @@ exists, so the first release has to be published by hand.
84
145
  - **Paths.** `dataRoot` is state; `projectDir` is the base for relative entry paths. `{id}{port}`
85
146
  `{host}{bind}{cwd}{projectDir}{dataRoot}{home}` and `${ENV}` expand in config; there is no
86
147
  package-relative state.
87
- - **Secrets never enter the config.** Password hash, bot token and TLS key live in the 0600 secrets
88
- file; the config holds policy.
148
+ - **Secrets never enter the config.** Password hash, API token hash, bot token and TLS key live in
149
+ the 0600 secrets file; the config holds policy.
150
+ - **A port holder that carries `HHOSTED_SERVER_ID` for this entry is our own successor, not a
151
+ stranger.** A program that restarts itself leaves a detached process behind; with
152
+ `follow` the panel adopts it as-is (pid, liveness, health, resources, stop — but not its output);
153
+ with `reclaim` it stops that successor and starts a fully supervised child instead. Both are strictly
154
+ better than blocking forever on a port that is already serving, and neither ever touches a stranger.
155
+ Ownership is read from the environment — `/proc` on Linux, `ps -E` on macOS, impossible on Windows —
156
+ and `stop.killPortHolders` stays the fallback.
157
+ - **A port is only ever freed by re-listing its listeners.** `POST /api/servers/:id/free-port` never
158
+ trusts a pid quoted in a message, and refuses any listener in `supervisedPids()` (the panel plus
159
+ every entry's child) instead of killing it — a port held by a sibling is a config mistake.
89
160
  - **Never expose beyond loopback without auth and a non-default password.** `checkExposure()` is the
90
161
  single rule, enforced at startup, on every settings write, and in the UI.
91
162
  - **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
@@ -106,8 +177,17 @@ exists, so the first release has to be published by hand.
106
177
  `stopping` first: overlapping calls would double-spawn or resurrect a stopped process. Tests must
107
178
  call `supervisor.dispose()`.
108
179
  - Port preflight re-probes after 300 ms — a just-closed listener can still complete a handshake.
180
+ - Vue does not notify a computed's subscribers when its recomputed value is `Object.is`-equal to the
181
+ old one, so anything mutated in place silently freezes every value derived from it. The log buffers
182
+ (`uis/stock/src/composables/useControlPlane.ts`) therefore hand out a **new array per batch**, and
183
+ the `version` counter only exists to make the views re-read at all. Getting this wrong is what kept
184
+ the live output view empty until a remount — a test that reads the array itself will not catch it.
185
+ - An adopted process is not a `ChildProcess`, so nothing reports its exit: the tick polls liveness and
186
+ hands the entry back to the normal `afterExit` path. Its output is not captured either — it was
187
+ redirected by whoever spawned it.
109
188
  - `stop.killPortHolders` frees a port only from a *listener* that is not our own process tree. Broad
110
189
  `lsof -ti:<port>` sweeps and pid-as-text parses have killed supervisors in the field; don't add one.
190
+ `free-port` reuses the same lookup and adds the supervisor's own pid set on top.
111
191
  - `ServerView.config.port` is normalized to `number | null`; the hand-narrowed types in
112
192
  `contracts.ts` are deliberate.
113
193
  - ArkType: an optional property (`'x?'`) rejects an explicit `undefined` (omit the key). Fields a UI
@@ -136,6 +216,6 @@ exists, so the first release has to be published by hand.
136
216
 
137
217
  ## Publishing
138
218
 
139
- `pnpm pack` runs `prepack` (a full build) and ships `bin/`, `dist/`, `uis/stock/dist`, `README.md`,
140
- `UI_CREATION.md`, `AGENTS.md` and `LICENSE`. The bin falls back to tsx so `pnpm link` works before a
219
+ `pnpm pack` runs `prepack` (a full build) and ships `bin/`, `dist/`, `uis/stock/dist`, `README.md`, `SERVERS.md`,
220
+ `NOTIFICATIONS.md`, `UI_CREATION.md`, `AGENTS.md` and `LICENSE`. The bin falls back to tsx so `pnpm link` works before a
141
221
  build; `vue`/`vue-router` are devDependencies because the UIs are prebuilt.
@@ -0,0 +1,69 @@
1
+ # Notifications
2
+
3
+ home-hosted can push supervision events to a Telegram chat. It is the only transport today, it is
4
+ opt-in, and the bot token never leaves the secrets file.
5
+
6
+ ## Setup
7
+
8
+ 1. Talk to [@BotFather](https://t.me/BotFather), run `/newbot`, and copy the token it prints.
9
+ 2. In the panel: **Settings → Notifications**, paste the token, press **Detect chats**.
10
+ 3. Send your bot a message from the chat you want the alerts in — a bot cannot open a conversation —
11
+ then pick that chat from the list (or paste its id).
12
+ 4. Press **Test**: the chat gets a message, and the panel shows the result and its timestamp.
13
+ 5. Turn on **Enabled**, then choose which events you want.
14
+
15
+ <details>
16
+ <summary><b>Doing it without the UI</b></summary>
17
+
18
+ ```bash
19
+ TOKEN='123456:ABC...'
20
+
21
+ curl -X POST http://127.0.0.1:3999/api/notifications/token \
22
+ -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
23
+ -d "{\"botToken\":\"$TOKEN\"}"
24
+
25
+ # which chats can this bot see (after you message it once)?
26
+ curl -X POST http://127.0.0.1:3999/api/notifications/detect-chats \
27
+ -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
28
+ -d "{\"botToken\":\"$TOKEN\"}"
29
+
30
+ curl -X POST http://127.0.0.1:3999/api/notifications/test \
31
+ -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
32
+ -d '{"chatId":"123456789"}'
33
+ ```
34
+
35
+ `detect-chats` and `test` accept an override, so a token can be tried before it is saved.
36
+ `DELETE /api/notifications/token` removes it.
37
+
38
+ </details>
39
+
40
+ ## What it sends
41
+
42
+ | event | toggle | default |
43
+ | --- | --- | --- |
44
+ | a server gave up restarting | `onCrash` | ✅ |
45
+ | a server exceeded its memory limit | `onCrash` | ✅ |
46
+ | a health check is failing | `onUnhealthy` | ✅ |
47
+ | a server was force-restarted (health timeout) | `onForcedRestart` | ✅ |
48
+ | a server recovered | `onRecovered` | ⬜ |
49
+ | host thresholds breached, and recovered | `onHost` | ✅ |
50
+
51
+ Host thresholds themselves — disk, memory, swap, load, temperature — are **Settings → Host**.
52
+
53
+ ## Quiet periods
54
+
55
+ `cooldownMs` (default two minutes) is per **server and reason**, so a flapping process cannot flood
56
+ the chat: it says "down", stays quiet while it flaps, and speaks again once the window passes. `0`
57
+ disables the throttle.
58
+
59
+ ## Where the token lives
60
+
61
+ `$HHOSTED_HOME/.control-secrets.json`, mode `0600`, next to the password and API-token hashes. It is
62
+ never written into `servers.config.json`, so committing or sharing a config cannot leak it — and
63
+ `GET /api/settings` reports `tokenSet: true|false`, never the token itself.
64
+
65
+ ## Delivery failures
66
+
67
+ Sends are fire-and-forget: supervision never waits on a chat API. A failure is logged and the last
68
+ result is shown in **Settings → Notifications** (`lastResult`, `lastResultAt`), so a wrong chat id is
69
+ visible there instead of silently swallowing alerts.