home-hosted 0.3.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 +87 -12
- package/NOTIFICATIONS.md +69 -0
- package/README.md +177 -171
- package/SERVERS.md +101 -0
- package/UI_CREATION.md +12 -5
- package/dist/cli.js +1196 -141
- package/dist/cli.js.map +1 -1
- package/package.json +5 -1
- package/uis/stock/dist/assets/index-DOD80X00.css +2 -0
- package/uis/stock/dist/assets/index-x_wy1ycR.js +47 -0
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/assets/index-CjmgiKjn.js +0 -47
- package/uis/stock/dist/assets/index-CmkrtNU0.css +0 -2
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
|
|
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/`,
|
|
9
|
-
daemon's pid/url/token, 0600. The package ships **no
|
|
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), `
|
|
48
|
-
|
|
49
|
-
|
|
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,49 @@ 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
|
+
- 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.
|
|
79
135
|
|
|
80
136
|
## Rules that matter
|
|
81
137
|
|
|
@@ -84,8 +140,18 @@ exists, so the first release has to be published by hand.
|
|
|
84
140
|
- **Paths.** `dataRoot` is state; `projectDir` is the base for relative entry paths. `{id}{port}`
|
|
85
141
|
`{host}{bind}{cwd}{projectDir}{dataRoot}{home}` and `${ENV}` expand in config; there is no
|
|
86
142
|
package-relative state.
|
|
87
|
-
- **Secrets never enter the config.** Password hash, bot token and TLS key live in
|
|
88
|
-
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.
|
|
89
155
|
- **Never expose beyond loopback without auth and a non-default password.** `checkExposure()` is the
|
|
90
156
|
single rule, enforced at startup, on every settings write, and in the UI.
|
|
91
157
|
- **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
|
|
@@ -106,8 +172,17 @@ exists, so the first release has to be published by hand.
|
|
|
106
172
|
`stopping` first: overlapping calls would double-spawn or resurrect a stopped process. Tests must
|
|
107
173
|
call `supervisor.dispose()`.
|
|
108
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.
|
|
109
183
|
- `stop.killPortHolders` frees a port only from a *listener* that is not our own process tree. Broad
|
|
110
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.
|
|
111
186
|
- `ServerView.config.port` is normalized to `number | null`; the hand-narrowed types in
|
|
112
187
|
`contracts.ts` are deliberate.
|
|
113
188
|
- ArkType: an optional property (`'x?'`) rejects an explicit `undefined` (omit the key). Fields a UI
|
|
@@ -136,6 +211,6 @@ exists, so the first release has to be published by hand.
|
|
|
136
211
|
|
|
137
212
|
## Publishing
|
|
138
213
|
|
|
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
|
|
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
|
|
141
216
|
build; `vue`/`vue-router` are devDependencies because the UIs are prebuilt.
|
package/NOTIFICATIONS.md
ADDED
|
@@ -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.
|