home-hosted 0.2.0 → 0.4.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/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,11 +22,18 @@ 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)
28
+ pnpm run media # regenerate docs/media (mockups, both served UIs, tour.gif)
24
29
  ```
25
30
 
26
- Releases are dispatched from `.github/workflows/release.yml` with a version: changelogen writes the
27
- changelog, bumps, commits and tags, the GitHub release carries the UI zips, and npm publishes through
28
- trusted publishing.
31
+ Releases are dispatched from `.github/workflows/release.yml` with a version (and a `dry-run` switch
32
+ that stops before pushing). It verifies the version against `package.json`, lints/types/tests,
33
+ builds the CLI plus the stock UI and every UI zip, lets changelogen write the changelog and tag
34
+ `v<version>`, creates the GitHub release with the UI bundles attached, and publishes to npm through
35
+ trusted publishing (OIDC, no token). npm only offers a trusted publisher for a package that already
36
+ exists, so the first release has to be published by hand.
29
37
 
30
38
  ## Architecture (and why)
31
39
 
@@ -40,11 +48,21 @@ trusted publishing.
40
48
  - `src/api/**` — one file per URL group (`$.routes.ts` = several routes), mirroring the path.
41
49
  - `src/shared/contracts.ts` — every ArkType schema (config, API and SSE DTOs), shared with the UIs;
42
50
  the OpenAPI spec is generated from it, never hand-written.
43
- - `src/config/` — `schema.ts` (on-disk shape), `store.ts` (validate/merge/atomic commit, reports
44
- `configError` instead of throwing on a bad file), `secrets.ts`, `seed.ts`.
45
- - `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.
46
58
  - `src/services/` — stateful orchestration: supervisor, control-server, state, auth + exposure,
47
- 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.
48
66
  - `src/helpers/` — paths (`dataRoot` vs `projectDir`), daemon (run.json + a loopback probe that
49
67
  bypasses `fetch`, so TLS with a self-signed pair still answers), error, validator, atomic,
50
68
  template, env-file, openapi, factory.
@@ -53,6 +71,11 @@ trusted publishing.
53
71
  `src/shared`, `@server` → `src` (**types only** — never import runtime server code into a UI).
54
72
  - `bin/home-hosted.mjs` — the published bin: `dist/cli.js`, or `src/cli.ts` through tsx when the
55
73
  build is missing (a linked checkout).
74
+ - `scripts/` — `build-uis.mjs` (build one UI, optionally zip it), `typecheck-uis.mjs`,
75
+ `capture-media.mjs` (the README's media), `check-release-version.mjs` and `release-notes.mjs`
76
+ (used by the release workflow), `dev.mjs` (`pnpm dev`).
77
+ - `docs/` — `mockups/*.html`, hand-written UI examples in four directions, and `media/*`: their
78
+ screenshots plus the README's `tour.gif`, all regenerated by `pnpm run media`.
56
79
 
57
80
  ## Conventions
58
81
 
@@ -66,7 +89,49 @@ trusted publishing.
66
89
  `http`) merge key-by-key, and an explicit `null` clears a key.
67
90
  - Two-sided bounds read inclusively (`'1 <= number.integer <= 512'`). `test/shared/contracts.test.ts`
68
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.
69
96
  - Conventional commits; ESLint via `@antfu/eslint-config`; sparse comments.
97
+ - A destructive action that is one click away confirms in a **popover** (`KillPortButton.vue`),
98
+ never by arming the same button for a second press: an impatient double click on an arming
99
+ button fires it. Keep the safe choice first in the popover's tab order.
100
+
101
+ ## Compatibility
102
+
103
+ Two surfaces outlive the release that wrote them: **configs absolutely, UIs within reason.** Breaking
104
+ either is a last resort, and never an accidental one.
105
+
106
+ - **A config written by an older release has to load in a newer one.** That direction is the priority:
107
+ add fields with defaults, never repurpose or remove one, and treat every existing key as permanent.
108
+ - **Both directions matter, and both are now handled.** An unrecognized key is read, reported and
109
+ left on disk instead of failing anything: it is the normal way a config from a newer release looks
110
+ here. Anything that is not merely unrecognized — a wrong value, a duplicate id, an unreadable file —
111
+ stops the panel instead of being papered over with defaults.
112
+ - **The UI moves in minor steps.** Routes, response fields and SSE frames are additive: keep the old
113
+ one and add the new one. A new response field is optional (`'x?'`) and read defensively, because an
114
+ upgrade writes a new `uis/stock/dist` while the old panel process keeps serving — and a
115
+ user-uploaded UI may be older than the panel it talks to.
116
+ - **Breaking is allowed; silent is not.** When nothing compatible can be done, say so in the final
117
+ answer *and* in the commit message with a `BREAKING CHANGE:` footer, naming the exact migration the
118
+ user must run.
119
+ - **A config records what wrote it.** Every write stamps a top-level `meta`
120
+ (`{ writtenBy, schema }`): the release that wrote the file and the config shape it wrote. An
121
+ unstamped file reads as the current schema, so nothing that existed before needed changing.
122
+ - **Nobody runs a config this release cannot read.** `up` refuses to start — exit 1, the exact
123
+ problem printed — when the file is unparseable, has an invalid value or a duplicate id, carries a
124
+ newer `meta.schema`, or has a registered migration pending. A panel that is *already* running keeps
125
+ the config it has and only reports the error, so a bad edit never disturbs supervision.
126
+ - **Unknown keys are dropped from the resolved config, kept on disk, and listed in a startup
127
+ warning.** Dropping one is the normal way a newer config looks here, so it must never fail the
128
+ group it sits in (that used to reset `control` — port, bind, auth policy — to schema defaults).
129
+ - **Migrations ship inside the package** (`src/config/migrations.ts`), are ordered, idempotent and
130
+ described in one line each. `home-hosted migrate` prints the plan, keeps `servers.config.json.bak`,
131
+ refuses to write a config it cannot read, and needs consent: `--yes`, `HHOSTED_MIGRATE=allow`, or a
132
+ person at a terminal. A detached daemon never migrates on its own. Starting a fetch of migration
133
+ code from GitHub was considered and rejected: the panel supervises processes, so remote code is an
134
+ RCE surface, and a migration would age against a newer store API anyway.
70
135
 
71
136
  ## Rules that matter
72
137
 
@@ -75,8 +140,18 @@ trusted publishing.
75
140
  - **Paths.** `dataRoot` is state; `projectDir` is the base for relative entry paths. `{id}{port}`
76
141
  `{host}{bind}{cwd}{projectDir}{dataRoot}{home}` and `${ENV}` expand in config; there is no
77
142
  package-relative state.
78
- - **Secrets never enter the config.** Password hash, bot token and TLS key live in the 0600 secrets
79
- file; the config holds policy.
143
+ - **Secrets never enter the config.** Password hash, API token hash, bot token and TLS key live in
144
+ the 0600 secrets file; the config holds policy.
145
+ - **A port holder that carries `HHOSTED_SERVER_ID` for this entry is our own successor, not a
146
+ stranger.** A program that restarts itself leaves a detached process behind; with
147
+ `follow` the panel adopts it as-is (pid, liveness, health, resources, stop — but not its output);
148
+ with `reclaim` it stops that successor and starts a fully supervised child instead. Both are strictly
149
+ better than blocking forever on a port that is already serving, and neither ever touches a stranger.
150
+ Ownership is read from the environment — `/proc` on Linux, `ps -E` on macOS, impossible on Windows —
151
+ and `stop.killPortHolders` stays the fallback.
152
+ - **A port is only ever freed by re-listing its listeners.** `POST /api/servers/:id/free-port` never
153
+ trusts a pid quoted in a message, and refuses any listener in `supervisedPids()` (the panel plus
154
+ every entry's child) instead of killing it — a port held by a sibling is a config mistake.
80
155
  - **Never expose beyond loopback without auth and a non-default password.** `checkExposure()` is the
81
156
  single rule, enforced at startup, on every settings write, and in the UI.
82
157
  - **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
@@ -97,8 +172,17 @@ trusted publishing.
97
172
  `stopping` first: overlapping calls would double-spawn or resurrect a stopped process. Tests must
98
173
  call `supervisor.dispose()`.
99
174
  - Port preflight re-probes after 300 ms — a just-closed listener can still complete a handshake.
175
+ - Vue does not notify a computed's subscribers when its recomputed value is `Object.is`-equal to the
176
+ old one, so anything mutated in place silently freezes every value derived from it. The log buffers
177
+ (`uis/stock/src/composables/useControlPlane.ts`) therefore hand out a **new array per batch**, and
178
+ the `version` counter only exists to make the views re-read at all. Getting this wrong is what kept
179
+ the live output view empty until a remount — a test that reads the array itself will not catch it.
180
+ - An adopted process is not a `ChildProcess`, so nothing reports its exit: the tick polls liveness and
181
+ hands the entry back to the normal `afterExit` path. Its output is not captured either — it was
182
+ redirected by whoever spawned it.
100
183
  - `stop.killPortHolders` frees a port only from a *listener* that is not our own process tree. Broad
101
184
  `lsof -ti:<port>` sweeps and pid-as-text parses have killed supervisors in the field; don't add one.
185
+ `free-port` reuses the same lookup and adds the supervisor's own pid set on top.
102
186
  - `ServerView.config.port` is normalized to `number | null`; the hand-narrowed types in
103
187
  `contracts.ts` are deliberate.
104
188
  - ArkType: an optional property (`'x?'`) rejects an explicit `undefined` (omit the key). Fields a UI
@@ -110,6 +194,10 @@ trusted publishing.
110
194
  encryption flags are cached and refreshed in the background.
111
195
  - `vite.server.config.ts` targets `node22` while `engines` requires >= 24 — deliberate margin, leave it.
112
196
  - `pnpm test` watches; CI runs `vitest run`.
197
+ - `pnpm run media` drives Chromium through Playwright, which needs fonts *and* the X/NSS/Mesa
198
+ libraries. In a bare container point `FONTCONFIG_PATH` at a `fonts.conf` covering any TTF and
199
+ `LD_LIBRARY_PATH` at a directory holding those libraries — without fonts, Skia panics instead of
200
+ rendering, and the failure looks like a broken page rather than a missing package.
113
201
 
114
202
  ## Where to extend
115
203
 
@@ -123,6 +211,6 @@ trusted publishing.
123
211
 
124
212
  ## Publishing
125
213
 
126
- `pnpm pack` runs `prepack` (a full build) and ships `bin/`, `dist/`, `uis/stock/dist`, `README.md`,
127
- `UI_CREATION.md`, `AGENTS.md` and `LICENSE`. The bin falls back to tsx so `pnpm link` works before a
214
+ `pnpm pack` runs `prepack` (a full build) and ships `bin/`, `dist/`, `uis/stock/dist`, `README.md`, `SERVERS.md`,
215
+ `NOTIFICATIONS.md`, `UI_CREATION.md`, `AGENTS.md` and `LICENSE`. The bin falls back to tsx so `pnpm link` works before a
128
216
  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.